Template
Redesign storefront to match minimal reference design
Product cards, header, footer, PDP, and typography reworked toward a leaner layout; adds shop policy pages backed by the Storefront API. - Type: switch to Geist Sans/Mono, regular-weight headings - Product cards: drop borders, rounded corners, and action buttons - Header: shorter bar, no bottom border, center-out hover underline, bag icon replacing the cart icon (drawer wording updated to match) - Footer: single line with policy links and social icons on bg-background - Policies: /policies/[handle] renders shop.privacyPolicy, termsOfService, refundPolicy, shippingPolicy, subscriptionPolicy (SSG, hourly revalidation) - PDP: image grid with mobile carousel + dots and click-to-zoom, sticky info column, colour swatches from option optionValues with a configurable name-to-colour fallback in config/swatches.ts, Shop-purple checkout button - Recommendations: left-aligned heading on bg-background - Ignore .env*.local and tsconfig.tsbuildinfo Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016jWbNNJLksC1QG8z8845FX
This commit is contained in:
co-authored by
Claude Opus 5
parent
e3d5e75299
commit
69f7435d6e
@@ -3,3 +3,5 @@ node_modules/*
|
||||
.next
|
||||
next-env.d.ts
|
||||
.yarn/install-state.gz
|
||||
.env*.local
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -16,10 +16,7 @@ export default function Page() {
|
||||
<CollectionDetail />
|
||||
<Footer
|
||||
storeName="Stride"
|
||||
logoUrl=""
|
||||
tagline="Your premium shopping destination"
|
||||
copyright="© 2026 Stride. All rights reserved."
|
||||
links={[]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,10 +16,7 @@ export default function Page() {
|
||||
<Collections title="Our Collections" />
|
||||
<Footer
|
||||
storeName="Stride"
|
||||
logoUrl=""
|
||||
tagline="Your premium shopping destination"
|
||||
copyright="© 2026 Stride. All rights reserved."
|
||||
links={[]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
+70
-7
@@ -33,6 +33,9 @@
|
||||
--color-destructive: hsl(0 84.2% 60.2%);
|
||||
--color-destructive-foreground: hsl(0 0% 98%);
|
||||
|
||||
/* Shop (accelerated checkout) purple */
|
||||
--color-shop: #5a31f4;
|
||||
|
||||
/* Border */
|
||||
--color-border: hsl(240 5.9% 90%);
|
||||
--color-input: hsl(240 5.9% 90%);
|
||||
@@ -44,10 +47,12 @@
|
||||
--radius-lg: 1rem;
|
||||
--radius-xl: 1.25rem;
|
||||
|
||||
/* Enhanced typography scale */
|
||||
--font-heading: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
--font-poppins: 'Poppins', system-ui, sans-serif;
|
||||
/* Typography — Geist Sans / Geist Mono */
|
||||
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-body: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Base styles for light, modern Shopify storefront */
|
||||
@@ -71,12 +76,70 @@ body {
|
||||
border-color: hsl(240 5.9% 85%);
|
||||
}
|
||||
|
||||
/* Bold heading styles */
|
||||
/* Heading styles — regular weight, slightly tightened like the Geist reference */
|
||||
h1,
|
||||
h2,
|
||||
.font-heading {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* Swipeable rows (mobile galleries) without a visible scrollbar */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Product description (HTML returned by the Storefront API) */
|
||||
.product-description p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.product-description > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Shop policy body (HTML returned by the Storefront API) */
|
||||
.policy-body p,
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.policy-body ul,
|
||||
.policy-body ol {
|
||||
padding-left: 1.25rem;
|
||||
list-style: revert;
|
||||
}
|
||||
|
||||
.policy-body li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.policy-body h2,
|
||||
.policy-body h3,
|
||||
.policy-body h4 {
|
||||
margin-top: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.policy-body strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.policy-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Enhanced button styles */
|
||||
|
||||
+6
-17
@@ -2,24 +2,16 @@
|
||||
|
||||
import React from 'react';
|
||||
import './globals.css';
|
||||
import { Space_Grotesk, Inter, Poppins } from 'next/font/google';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
|
||||
const poppins = Poppins({
|
||||
const geist = Geist({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-poppins',
|
||||
variable: '--font-geist-sans',
|
||||
});
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700'],
|
||||
variable: '--font-heading',
|
||||
});
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600'],
|
||||
variable: '--font-body',
|
||||
variable: '--font-geist-mono',
|
||||
});
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -28,10 +20,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${poppins.variable} ${spaceGrotesk.variable} ${inter.variable}`}
|
||||
>
|
||||
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
||||
<body className="font-body antialiased bg-background text-foreground m-0 p-0">
|
||||
{children}
|
||||
</body>
|
||||
|
||||
+4
-4
@@ -13,13 +13,13 @@ export default function Page() {
|
||||
{ label: 'Collections', url: '/collections' },
|
||||
]}
|
||||
/>
|
||||
<Products title="Shop All" />
|
||||
<Products
|
||||
title="Shopify Hydrogen Storefront"
|
||||
subtitle="An agent-friendly Shopify storefront built with Next.js and Hydrogen."
|
||||
/>
|
||||
<Footer
|
||||
storeName="Stride"
|
||||
logoUrl=""
|
||||
tagline="Your premium shopping destination"
|
||||
copyright="© 2026 Stride. All rights reserved."
|
||||
links={[]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Header from '@/components/shopify/header';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
|
||||
|
||||
export function generateStaticParams() {
|
||||
return POLICY_HANDLES.map((handle) => ({ handle }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}) {
|
||||
const { handle } = await params;
|
||||
const policy = await getShopPolicy(handle);
|
||||
|
||||
return {
|
||||
title: policy ? `${policy.title} — Stride` : 'Policy — Stride',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PolicyPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}) {
|
||||
const { handle } = await params;
|
||||
const policy = await getShopPolicy(handle);
|
||||
|
||||
if (!policy) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
storeName="Stride"
|
||||
logoUrl=""
|
||||
links={[
|
||||
{ label: 'Products', url: '/' },
|
||||
{ label: 'Collections', url: '/collections' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-5 lg:px-10 py-16">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||
{policy.title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: policy.body }}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer
|
||||
storeName="Stride"
|
||||
copyright="© 2026 Stride. All rights reserved."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,21 +14,11 @@ export default function Page() {
|
||||
{ label: 'Collections', url: '/collections' },
|
||||
]}
|
||||
/>
|
||||
<ProductDetail
|
||||
addToCartLabel="Add to Cart"
|
||||
features={[
|
||||
{ icon: 'RiTruckLine', label: 'Free shipping on orders over $100' },
|
||||
{ icon: 'RiArrowGoBackLine', label: '30-day return policy' },
|
||||
{ icon: 'RiSecurePaymentLine', label: 'Secure payment' },
|
||||
]}
|
||||
/>
|
||||
<ProductRecommendations title="You Might Also Like" />
|
||||
<ProductDetail addToCartLabel="Add to Cart" />
|
||||
<ProductRecommendations title="You May Also Like" />
|
||||
<Footer
|
||||
storeName="Stride"
|
||||
logoUrl=""
|
||||
tagline="Your premium shopping destination"
|
||||
copyright="© 2026 Stride. All rights reserved."
|
||||
links={[]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,10 +16,7 @@ export default function Page() {
|
||||
<Collections title="Our Collections" />
|
||||
<Footer
|
||||
storeName="Stride"
|
||||
logoUrl=""
|
||||
tagline="Your premium shopping destination"
|
||||
copyright="© 2026 Stride. All rights reserved."
|
||||
links={[]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RiImageLine,
|
||||
RiSubtractLine,
|
||||
RiAddLine,
|
||||
RiShoppingBagLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
Empty,
|
||||
@@ -65,8 +66,9 @@ const CartDrawer: React.FC = () => {
|
||||
{/* Header */}
|
||||
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<SheetTitle className="text-base">
|
||||
Shopping Cart ({itemCount})
|
||||
<SheetTitle className="text-base flex items-center gap-x-2">
|
||||
<RiShoppingBagLine size={18} />
|
||||
Bag ({itemCount})
|
||||
</SheetTitle>
|
||||
<Button onClick={closeCart} variant="ghost" size="icon-sm">
|
||||
<RiCloseLine size={20} />
|
||||
@@ -83,7 +85,7 @@ const CartDrawer: React.FC = () => {
|
||||
) : items.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Your cart is empty</EmptyTitle>
|
||||
<EmptyTitle>Your bag is empty</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Add some products to get started!
|
||||
</EmptyDescription>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiInstagramLine,
|
||||
RiTiktokLine,
|
||||
RiFacebookFill,
|
||||
} from '@remixicon/react';
|
||||
|
||||
export interface FooterLink {
|
||||
label: string;
|
||||
@@ -7,110 +12,63 @@ export interface FooterLink {
|
||||
|
||||
interface FooterProps {
|
||||
storeName?: string;
|
||||
logoUrl?: string;
|
||||
tagline?: string;
|
||||
copyright?: string;
|
||||
links?: FooterLink[];
|
||||
instagramUrl?: string;
|
||||
tiktokUrl?: string;
|
||||
facebookUrl?: string;
|
||||
}
|
||||
|
||||
const Footer: React.FC<FooterProps> = ({
|
||||
storeName = 'Stride',
|
||||
logoUrl,
|
||||
tagline = 'Performance in every stride.',
|
||||
copyright = '© 2026 Stride. All rights reserved.',
|
||||
storeName = 'Shop',
|
||||
copyright,
|
||||
links = [
|
||||
{ label: 'About', url: '#' },
|
||||
{ label: 'Athletes', url: '#' },
|
||||
{ label: 'Technology', url: '#' },
|
||||
{ label: 'Contact', url: '#' },
|
||||
{ label: 'Terms of Service', url: '/policies/terms-of-service' },
|
||||
{ label: 'Privacy Policy', url: '/policies/privacy-policy' },
|
||||
{ label: 'Refund Policy', url: '/policies/refund-policy' },
|
||||
{ label: 'Shipping Policy', url: '/policies/shipping-policy' },
|
||||
{ label: 'Subscription Policy', url: '/policies/subscription-policy' },
|
||||
],
|
||||
instagramUrl = '#',
|
||||
tiktokUrl = '#',
|
||||
facebookUrl = '#',
|
||||
}) => {
|
||||
const socials = [
|
||||
{ label: 'Instagram', url: instagramUrl, Icon: RiInstagramLine },
|
||||
{ label: 'TikTok', url: tiktokUrl, Icon: RiTiktokLine },
|
||||
{ label: 'Facebook', url: facebookUrl, Icon: RiFacebookFill },
|
||||
];
|
||||
|
||||
return (
|
||||
<footer className="bg-zinc-50 border-t border-border py-16 text-sm">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-y-12">
|
||||
{/* Brand Column */}
|
||||
<div className="md:col-span-5">
|
||||
<div className="flex items-baseline mb-4">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={storeName}
|
||||
className="h-8 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-4xl font-semibold tracking-tighter font-poppins text-foreground">
|
||||
{storeName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="max-w-xs text-muted-foreground text-[15px] leading-relaxed">
|
||||
{tagline}
|
||||
<footer className="bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 py-10">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-5">
|
||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-x-5 gap-y-2">
|
||||
<p className="text-sm text-muted-foreground leading-5">
|
||||
{copyright || `© ${storeName}. All rights reserved.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 text-xs font-mono text-muted-foreground tracking-[1px]">
|
||||
ENGINEERED FOR MOTION
|
||||
</div>
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.url}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="md:col-span-3">
|
||||
<div className="font-semibold text-foreground mb-5 text-xs tracking-widest">
|
||||
SHOP
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-3 text-muted-foreground">
|
||||
{links.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.url}
|
||||
className="hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4">
|
||||
<div className="font-semibold text-foreground mb-5 text-xs tracking-widest">
|
||||
CONNECT
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-3 text-muted-foreground">
|
||||
<div className="flex items-center gap-x-4">
|
||||
{socials.map(({ label, url, Icon }) => (
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-foreground transition-colors w-fit"
|
||||
key={label}
|
||||
href={url}
|
||||
aria-label={label}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Instagram
|
||||
<Icon size={18} />
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
Pinterest
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
Newsletter
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-12 text-[10px] text-muted-foreground font-mono leading-loose">
|
||||
CRAFTED WITH PRECISION
|
||||
<br />
|
||||
IN A NEUTRAL PALETTE
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="mt-16 pt-8 border-t border-border text-xs text-muted-foreground flex flex-col md:flex-row justify-between items-center gap-4 font-mono tracking-widest">
|
||||
<div>{copyright}</div>
|
||||
<div className="flex gap-x-6">
|
||||
<span>Privacy</span>
|
||||
<span>Terms</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useCartStore } from '@/hooks/use-shopify-cart';
|
||||
import CartDrawer from '@/components/shopify/cart-drawer';
|
||||
import { RiShoppingCartLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||
|
||||
const CartIcon: React.FC = () => {
|
||||
const toggleCart = useCartStore((s) => s.toggleCart);
|
||||
@@ -15,11 +15,12 @@ const CartIcon: React.FC = () => {
|
||||
return (
|
||||
<button
|
||||
onClick={toggleCart}
|
||||
className="relative p-2.5 text-foreground hover:text-primary transition-all duration-200 rounded-full hover:bg-secondary"
|
||||
aria-label={`Open bag (${itemCount})`}
|
||||
className="relative p-2 text-foreground hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<RiShoppingCartLine size={20} />
|
||||
<RiShoppingBagLine size={20} />
|
||||
{itemCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-5 h-5 flex items-center justify-center font-semibold shadow">
|
||||
<span className="absolute top-0 right-0 bg-primary text-primary-foreground text-[10px] font-mono rounded-full w-4 h-4 flex items-center justify-center">
|
||||
{itemCount > 99 ? '99+' : itemCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -49,31 +50,31 @@ const Header: React.FC<HeaderProps> = ({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<nav className="bg-white/95 backdrop-blur-md border-b border-border sticky top-0 z-50">
|
||||
<nav className="bg-background/95 backdrop-blur-md sticky top-0 z-50">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<div className="flex justify-between items-center h-20">
|
||||
<div className="flex justify-between items-center h-14">
|
||||
{/* Logo */}
|
||||
<Link href="/" className="flex items-center group">
|
||||
<Link href="/" className="flex items-center">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={storeName}
|
||||
className="h-9 w-auto object-contain"
|
||||
className="h-6 w-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-4xl font-semibold tracking-tighter font-poppins text-foreground group-hover:text-primary transition-colors">
|
||||
<span className="text-base font-medium tracking-tight text-foreground">
|
||||
{storeName}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-x-10 text-sm font-medium">
|
||||
<div className="hidden md:flex items-center gap-x-8 text-sm">
|
||||
{links.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.url}
|
||||
className="text-foreground hover:text-primary relative after:absolute after:bottom-[-2px] after:left-0 after:h-[2px] after:w-0 after:bg-primary after:transition-all hover:after:w-full"
|
||||
className="text-foreground relative after:absolute after:bottom-[-4px] after:left-1/2 after:-translate-x-1/2 after:h-px after:w-0 after:bg-foreground after:transition-[width] after:duration-300 hover:after:w-full"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
@@ -81,16 +82,16 @@ const Header: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<CartIcon />
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="md:hidden p-2.5 text-foreground hover:text-primary transition-all rounded-full hover:bg-secondary"
|
||||
className="md:hidden p-2 text-foreground hover:text-muted-foreground transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? <RiCloseLine size={28} /> : <RiMenu3Line size={28} />}
|
||||
{menuOpen ? <RiCloseLine size={22} /> : <RiMenu3Line size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,21 +99,18 @@ const Header: React.FC<HeaderProps> = ({
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{menuOpen && (
|
||||
<div className="md:hidden border-t bg-white">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 py-8 flex flex-col gap-y-6 text-lg font-medium">
|
||||
<div className="md:hidden bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 pb-6 flex flex-col gap-y-4 text-sm">
|
||||
{links.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.url}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="text-foreground hover:text-primary transition-colors py-1"
|
||||
className="text-foreground hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="pt-4 border-t text-xs text-muted-foreground font-mono tracking-widest">
|
||||
PROFESSIONAL • CLEAN • MODERN
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useShopifyCart } from '@/hooks/use-shopify-cart';
|
||||
import { truncate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader } from '@/app/components/ui/loader';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
@@ -50,13 +44,9 @@ interface Product {
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
onAddToCart?: (product: Product) => void;
|
||||
}
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => {
|
||||
const { addItem, openCart } = useShopifyCart();
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
||||
const firstImage = product.images.edges[0]?.node;
|
||||
const price = product.priceRange.minVariantPrice;
|
||||
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
|
||||
@@ -70,109 +60,56 @@ const ProductCard: React.FC<ProductCardProps> = ({ product, onAddToCart }) => {
|
||||
return `$${parseFloat(amount).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const handleAddToCart = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!firstVariant || !isAvailable || isAdding) return;
|
||||
|
||||
try {
|
||||
setIsAdding(true);
|
||||
await addItem(firstVariant.id, 1);
|
||||
openCart();
|
||||
if (onAddToCart) onAddToCart(product);
|
||||
} catch (err) {
|
||||
console.error('Failed to add item to cart:', err);
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-modern group bg-card rounded-2xl overflow-hidden h-full flex flex-col">
|
||||
<Link
|
||||
href={`/products/${product.handle}`}
|
||||
className="group block h-full"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="relative aspect-[4/3.1] bg-zinc-100 overflow-hidden">
|
||||
<Link href={`/products/${product.handle}`} className="block h-full">
|
||||
{firstImage ? (
|
||||
<img
|
||||
src={firstImage.url}
|
||||
alt={firstImage.altText || product.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-[1.08]"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
|
||||
<i className="ri-image-line text-8xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div className="relative aspect-square overflow-hidden bg-white">
|
||||
{firstImage ? (
|
||||
<img
|
||||
src={firstImage.url}
|
||||
alt={firstImage.altText || product.title}
|
||||
className="w-full h-full object-contain transition-transform duration-500 group-hover:scale-[1.04]"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
|
||||
<i className="ri-image-line text-8xl"></i>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges */}
|
||||
{hasDiscount && compareAtPrice && (
|
||||
<Badge className="absolute top-4 left-4 bg-rose-600 hover:bg-rose-600 text-white text-xs font-medium px-3 py-1 shadow-sm">
|
||||
<span className="absolute top-0 left-0 text-[11px] font-mono tracking-widest text-rose-600">
|
||||
SALE
|
||||
</Badge>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isAvailable && (
|
||||
<div className="absolute top-4 right-4 bg-white/90 text-xs font-medium px-3 py-1 rounded-full border text-foreground/70">
|
||||
<span className="absolute top-0 right-0 text-[11px] font-mono tracking-widest text-muted-foreground">
|
||||
SOLD OUT
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1 p-6 flex flex-col">
|
||||
<Link
|
||||
href={`/products/${product.handle}`}
|
||||
className="group-hover:text-primary transition-colors"
|
||||
>
|
||||
<h3 className="font-heading text-xl font-semibold tracking-tight text-foreground mb-2 line-clamp-2 min-h-[3.2em]">
|
||||
{truncate(product.title, 65)}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="flex flex-col flex-1 py-2.5">
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
||||
{truncate(product.title, 65)}
|
||||
</h3>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="price-display flex items-baseline gap-x-3 mb-6">
|
||||
<span className="text-2xl font-semibold text-foreground tracking-tighter">
|
||||
{formatPrice(price.amount)}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-mono tabular-nums tracking-tight text-sm text-foreground">
|
||||
{formatPrice(price.amount)}
|
||||
</span>
|
||||
{hasDiscount && compareAtPrice && (
|
||||
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
|
||||
{formatPrice(compareAtPrice.amount)}
|
||||
</span>
|
||||
{hasDiscount && compareAtPrice && (
|
||||
<span className="text-base line-through text-muted-foreground">
|
||||
{formatPrice(compareAtPrice.amount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href={`/products/${product.handle}`} className="flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full btn-modern text-sm font-medium"
|
||||
>
|
||||
DETAILS
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{isAvailable && (
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 btn-modern bg-primary hover:bg-primary/90 text-sm font-medium"
|
||||
disabled={isAdding || !isAvailable}
|
||||
>
|
||||
{isAdding ? (
|
||||
<>
|
||||
<Loader size={16} className="mr-2" />
|
||||
ADDING...
|
||||
</>
|
||||
) : (
|
||||
'ADD TO BAG'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useProduct, type Product } from '@/hooks/use-shopify-products';
|
||||
import { useShopifyCart } from '@/hooks/use-shopify-cart';
|
||||
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||
import ProductDetailGallery from './product-detail-gallery';
|
||||
import ProductDetailInfo, { type ProductFeature } from './product-detail-info';
|
||||
import ProductDetailInfo from './product-detail-info';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
@@ -15,14 +14,6 @@ import {
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
@@ -47,17 +38,15 @@ export type { Product };
|
||||
interface ProductDetailProps {
|
||||
handle?: string;
|
||||
addToCartLabel?: string;
|
||||
features?: ProductFeature[];
|
||||
}
|
||||
|
||||
const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
handle: handleProp,
|
||||
addToCartLabel = 'Add to Cart',
|
||||
features,
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const handle = handleProp || (params?.handle as string);
|
||||
const { addItem, openCart } = useShopifyCart();
|
||||
const { addItem, openCart, checkoutUrl } = useShopifyCart();
|
||||
|
||||
const { product, loading, error } = useProduct(handle);
|
||||
|
||||
@@ -68,8 +57,8 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
const [addingToCart, setAddingToCart] = useState(false);
|
||||
const [buyingNow, setBuyingNow] = useState(false);
|
||||
|
||||
// Initialize variant when product loads
|
||||
useEffect(() => {
|
||||
@@ -102,17 +91,6 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
|
||||
if (matchingVariant) {
|
||||
setSelectedVariant(matchingVariant.node);
|
||||
|
||||
// Update image if variant has an associated image
|
||||
if (matchingVariant.node.image && product) {
|
||||
const variantImageUrl = matchingVariant.node.image.url;
|
||||
const imageIndex = product.images.edges.findIndex(
|
||||
(edge) => edge.node.url === variantImageUrl
|
||||
);
|
||||
if (imageIndex !== -1) {
|
||||
setSelectedImageIndex(imageIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,30 +108,50 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Adds the item, then sends the shopper straight to the Shopify checkout
|
||||
// (where Shop Pay is offered) rather than opening the cart drawer.
|
||||
const handleBuyNow = async () => {
|
||||
if (!selectedVariant || !product) return;
|
||||
|
||||
try {
|
||||
setBuyingNow(true);
|
||||
const updatedCart = await addItem(selectedVariant.id, quantity);
|
||||
const url = updatedCart?.checkoutUrl ?? checkoutUrl;
|
||||
|
||||
if (url) {
|
||||
redirectToCheckout(url);
|
||||
} else {
|
||||
openCart();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to start checkout:', err);
|
||||
} finally {
|
||||
setBuyingNow(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10">
|
||||
{/* Image Gallery Skeleton */}
|
||||
<div>
|
||||
<div className="aspect-square bg-gray-200 rounded-lg animate-pulse mb-4"></div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-gray-200 rounded animate-pulse"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="lg:col-span-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square bg-zinc-100 animate-pulse"
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Product Info Skeleton */}
|
||||
<div>
|
||||
<div className="h-8 bg-gray-200 rounded mb-4 animate-pulse"></div>
|
||||
<div className="h-6 bg-gray-200 rounded mb-6 w-1/3 animate-pulse"></div>
|
||||
<div className="h-24 bg-gray-200 rounded mb-6 animate-pulse"></div>
|
||||
<div className="h-12 bg-gray-200 rounded mb-4 animate-pulse"></div>
|
||||
<div className="h-12 bg-gray-200 rounded animate-pulse"></div>
|
||||
<div className="lg:col-span-2 animate-pulse">
|
||||
<div className="h-8 bg-zinc-100 w-2/3"></div>
|
||||
<div className="h-5 bg-zinc-100 w-24 mt-2"></div>
|
||||
<div className="h-8 bg-zinc-100 w-32 mt-8"></div>
|
||||
<div className="h-10 bg-zinc-100 mt-8"></div>
|
||||
<div className="h-12 bg-zinc-100 mt-8"></div>
|
||||
<div className="h-12 bg-zinc-100 mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,39 +179,29 @@ const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Breadcrumb className="mb-6">
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/">Home</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{product.title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<ProductDetailGallery
|
||||
images={product.images.edges.map((edge) => edge.node)}
|
||||
selectedImageIndex={selectedImageIndex}
|
||||
onImageSelect={setSelectedImageIndex}
|
||||
/>
|
||||
<ProductDetailInfo
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
selectedOptions={selectedOptions}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
handleAddToCart={handleAddToCart}
|
||||
onOptionChange={handleOptionChange}
|
||||
loading={addingToCart}
|
||||
addToCartLabel={addToCartLabel}
|
||||
features={features}
|
||||
/>
|
||||
<div className="bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-10 items-start">
|
||||
<div className="lg:col-span-3">
|
||||
<ProductDetailGallery
|
||||
images={product.images.edges.map((edge) => edge.node)}
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-2 lg:sticky lg:top-20">
|
||||
<ProductDetailInfo
|
||||
product={product}
|
||||
selectedVariant={selectedVariant}
|
||||
selectedOptions={selectedOptions}
|
||||
quantity={quantity}
|
||||
setQuantity={setQuantity}
|
||||
handleAddToCart={handleAddToCart}
|
||||
handleBuyNow={handleBuyNow}
|
||||
onOptionChange={handleOptionChange}
|
||||
loading={addingToCart}
|
||||
buyingNow={buyingNow}
|
||||
addToCartLabel={addToCartLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { RiCloseLine } from '@remixicon/react';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
@@ -7,57 +10,148 @@ interface ProductImage {
|
||||
|
||||
interface ProductDetailGalleryProps {
|
||||
images: ProductImage[];
|
||||
selectedImageIndex?: number;
|
||||
onImageSelect?: (index: number) => void;
|
||||
}
|
||||
|
||||
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
images,
|
||||
selectedImageIndex = 0,
|
||||
onImageSelect,
|
||||
}) => {
|
||||
const setSelectedImage = onImageSelect || (() => {});
|
||||
const [zoomedIndex, setZoomedIndex] = useState<number | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const isZoomed = zoomedIndex !== null;
|
||||
|
||||
const close = useCallback(() => setZoomedIndex(null), []);
|
||||
|
||||
// The slide whose left edge sits closest to the scroller's left edge is the
|
||||
// one in view. Measuring rects keeps this correct whatever the gap or width.
|
||||
const handleScroll = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
const scrollerLeft = scroller.getBoundingClientRect().left;
|
||||
let nearest = 0;
|
||||
let smallestOffset = Infinity;
|
||||
|
||||
Array.from(scroller.children).forEach((child, index) => {
|
||||
const offset = Math.abs(child.getBoundingClientRect().left - scrollerLeft);
|
||||
if (offset < smallestOffset) {
|
||||
smallestOffset = offset;
|
||||
nearest = index;
|
||||
}
|
||||
});
|
||||
|
||||
setActiveIndex(nearest);
|
||||
}, []);
|
||||
|
||||
const scrollToIndex = (index: number) => {
|
||||
const scroller = scrollerRef.current;
|
||||
const slide = scroller?.children[index];
|
||||
if (!scroller || !slide) return;
|
||||
|
||||
const offset =
|
||||
slide.getBoundingClientRect().left - scroller.getBoundingClientRect().left;
|
||||
scroller.scrollTo({ left: scroller.scrollLeft + offset, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Close on Escape, and keep the page behind the overlay from scrolling.
|
||||
useEffect(() => {
|
||||
if (!isZoomed) return;
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') close();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [isZoomed, close]);
|
||||
|
||||
if (images.length === 0) {
|
||||
return (
|
||||
<div className="aspect-square bg-zinc-50 flex items-center justify-center text-zinc-300">
|
||||
<i className="ri-image-line text-[120px]"></i>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSingle = images.length === 1;
|
||||
const zoomedImage = zoomedIndex !== null ? images[zoomedIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Main Image */}
|
||||
<div className="product-gallery-main aspect-square bg-zinc-50 border border-border rounded-3xl overflow-hidden shadow-inner relative">
|
||||
{images.length > 0 ? (
|
||||
<img
|
||||
src={images[selectedImageIndex].url}
|
||||
alt={images[selectedImageIndex].altText || 'Product image'}
|
||||
className="w-full h-full object-cover transition-all hover:scale-105 duration-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
|
||||
<i className="ri-image-line text-[120px]"></i>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
{/* Swipeable carousel on mobile, grid from sm up */}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar sm:grid sm:grid-cols-2 sm:overflow-visible"
|
||||
>
|
||||
{images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setZoomedIndex(index)}
|
||||
aria-label={`Zoom ${image.altText || 'product image'}`}
|
||||
className={`relative aspect-square w-full shrink-0 snap-center overflow-hidden bg-white cursor-zoom-in sm:shrink ${
|
||||
isSingle ? 'sm:col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || 'Product image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Image Thumbnails */}
|
||||
{images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{images.map((image, index) => (
|
||||
{/* Carousel pagination — the grid needs no dots, so mobile only */}
|
||||
{!isSingle && (
|
||||
<div className="flex justify-center gap-2 pt-4 sm:hidden">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setSelectedImage(index)}
|
||||
className={`aspect-square rounded-2xl overflow-hidden border transition-all duration-200 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary/30 ${
|
||||
selectedImageIndex === index
|
||||
? 'border-primary shadow-md scale-[1.03]'
|
||||
: 'border-border hover:border-zinc-300'
|
||||
onClick={() => scrollToIndex(index)}
|
||||
aria-label={`Go to image ${index + 1}`}
|
||||
aria-current={index === activeIndex}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
index === activeIndex
|
||||
? 'w-5 bg-foreground'
|
||||
: 'w-1.5 bg-border hover:bg-foreground/40'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || 'Product thumbnail'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{zoomedImage && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={zoomedImage.altText || 'Product image'}
|
||||
onClick={close}
|
||||
className="fixed inset-0 z-100 flex items-center justify-center bg-foreground/20 backdrop-blur-md p-6 md:p-10"
|
||||
>
|
||||
<img
|
||||
src={zoomedImage.url}
|
||||
alt={zoomedImage.altText || 'Product image'}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="max-h-full max-w-full object-contain bg-white"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={close}
|
||||
aria-label="Close"
|
||||
className="absolute top-4 right-4 h-10 w-10 rounded-full bg-background text-foreground flex items-center justify-center shadow-sm hover:bg-secondary transition-colors"
|
||||
>
|
||||
<RiCloseLine size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader } from '@/app/components/ui/loader';
|
||||
import RemixIcon from '@/components/ui/remix-icon';
|
||||
import { RiSubtractLine, RiAddLine } from '@remixicon/react';
|
||||
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
|
||||
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
|
||||
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
|
||||
|
||||
interface ProductPrice {
|
||||
amount: string;
|
||||
@@ -20,12 +21,6 @@ interface ProductVariant {
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
id: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -53,12 +48,29 @@ interface ProductDetailInfoProps {
|
||||
quantity: number;
|
||||
setQuantity: (quantity: number) => void;
|
||||
handleAddToCart: () => void;
|
||||
handleBuyNow?: () => void;
|
||||
onOptionChange: (optionName: string, value: string) => void;
|
||||
loading?: boolean;
|
||||
buyingNow?: boolean;
|
||||
addToCartLabel?: string;
|
||||
features?: ProductFeature[];
|
||||
}
|
||||
|
||||
// A swatch comes from the option value's own swatch (colour or image) or the
|
||||
// colour its name implies (see config/swatches) — never a variant photo.
|
||||
const swatchStyle = (
|
||||
value: ProductOptionValue
|
||||
): { background?: string; image?: string } => {
|
||||
if (value.swatch?.color) return { background: value.swatch.color };
|
||||
|
||||
const swatchImage = value.swatch?.image?.previewImage?.url;
|
||||
if (swatchImage) return { image: swatchImage };
|
||||
|
||||
const namedColor = swatchColorForName(value.name);
|
||||
if (namedColor) return { background: namedColor };
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
product,
|
||||
selectedVariant,
|
||||
@@ -66,14 +78,11 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
quantity,
|
||||
setQuantity,
|
||||
handleAddToCart,
|
||||
handleBuyNow,
|
||||
onOptionChange,
|
||||
loading = false,
|
||||
buyingNow = false,
|
||||
addToCartLabel = 'Add to Cart',
|
||||
features = [
|
||||
{ icon: 'RiTruckLine', label: 'Free shipping on orders over $100' },
|
||||
{ icon: 'RiArrowGoBackLine', label: '30-day return policy' },
|
||||
{ icon: 'RiSecurePaymentLine', label: 'Secure payment' },
|
||||
],
|
||||
}) => {
|
||||
const formatPrice = (amount: string) => {
|
||||
return `$${parseFloat(amount).toFixed(2)}`;
|
||||
@@ -84,48 +93,152 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
const hasDiscount =
|
||||
compareAtPrice &&
|
||||
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
|
||||
const isAvailable = selectedVariant?.availableForSale ?? false;
|
||||
|
||||
const isSwatchOption = (option: ProductOption) =>
|
||||
isSwatchOptionName(option.name);
|
||||
|
||||
// Some products return Size before Color; show the swatches first either way.
|
||||
const orderedOptions = [...(product.options ?? [])].sort(
|
||||
(a, b) => Number(isSwatchOption(b)) - Number(isSwatchOption(a))
|
||||
);
|
||||
|
||||
// `optionValues` carries the swatch data; fall back to plain `values`.
|
||||
const optionValuesFor = (option: ProductOption): ProductOptionValue[] =>
|
||||
option.optionValues?.length
|
||||
? option.optionValues
|
||||
: option.values.map((value) => ({ id: value, name: value }));
|
||||
|
||||
return (
|
||||
<div className="pt-2">
|
||||
<div className="mb-2">
|
||||
<div className="uppercase tracking-[3px] text-xs font-mono text-muted-foreground mb-1">
|
||||
LUMINA COLLECTION
|
||||
</div>
|
||||
<h1 className="text-5xl md:text-6xl font-bold tracking-tighter font-heading text-foreground leading-none mb-6">
|
||||
{product.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center gap-x-4 mb-10">
|
||||
<span className="text-4xl font-semibold tracking-tighter text-foreground price-display">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
|
||||
{product.title}
|
||||
</h1>
|
||||
<div className="flex items-baseline gap-x-3 mt-1">
|
||||
<span className="font-mono tabular-nums tracking-tight text-base text-foreground">
|
||||
{formatPrice(price.amount)}
|
||||
</span>
|
||||
{hasDiscount && compareAtPrice && (
|
||||
<>
|
||||
<span className="text-2xl text-muted-foreground line-through tracking-tight">
|
||||
{formatPrice(compareAtPrice.amount)}
|
||||
</span>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs font-mono px-4 py-1"
|
||||
>
|
||||
SAVE{' '}
|
||||
{Math.round(
|
||||
((parseFloat(compareAtPrice.amount) -
|
||||
parseFloat(price.amount)) /
|
||||
parseFloat(compareAtPrice.amount)) *
|
||||
100
|
||||
)}
|
||||
%
|
||||
</Badge>
|
||||
</>
|
||||
<span className="font-mono tabular-nums tracking-tight text-sm line-through text-muted-foreground">
|
||||
{formatPrice(compareAtPrice.amount)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Options — colour swatches lead, whatever order the API returns */}
|
||||
{orderedOptions.map((option) => {
|
||||
const isSwatch = isSwatchOption(option);
|
||||
const selected = selectedOptions[option.name];
|
||||
|
||||
return (
|
||||
<div key={option.id} className="mt-8">
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{option.name}
|
||||
{isSwatch && selected && (
|
||||
<span className="text-foreground font-medium">: {selected}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{optionValuesFor(option).map((value) => {
|
||||
const isSelected = selected === value.name;
|
||||
|
||||
if (isSwatch) {
|
||||
const { background, image } = swatchStyle(value);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => onOptionChange(option.name, value.name)}
|
||||
title={value.name}
|
||||
aria-label={value.name}
|
||||
aria-pressed={isSelected}
|
||||
className={`h-8 w-8 rounded-full bg-cover bg-center transition-shadow ${
|
||||
isSelected
|
||||
? 'ring-2 ring-foreground ring-offset-2'
|
||||
: 'ring-1 ring-border hover:ring-foreground/40'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: background,
|
||||
backgroundImage: image ? `url(${image})` : undefined,
|
||||
}}
|
||||
>
|
||||
{!background && !image && (
|
||||
<span className="text-[10px]">{value.name.at(0)}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => onOptionChange(option.name, value.name)}
|
||||
aria-pressed={isSelected}
|
||||
className={`min-w-14 px-5 py-2 rounded-md border text-sm text-center transition-colors ${
|
||||
isSelected
|
||||
? 'border-foreground text-foreground'
|
||||
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{value.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Quantity + Add to Cart */}
|
||||
<div className="mt-8 flex items-stretch gap-3">
|
||||
<div className="flex items-center rounded-md border border-border h-11">
|
||||
<button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
disabled={quantity <= 1}
|
||||
aria-label="Decrease quantity"
|
||||
className="h-full px-3 text-foreground disabled:text-muted-foreground/50 transition-colors"
|
||||
>
|
||||
<RiSubtractLine size={16} />
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm tabular-nums">
|
||||
{quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
aria-label="Increase quantity"
|
||||
className="h-full px-3 text-foreground transition-colors"
|
||||
>
|
||||
<RiAddLine size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!isAvailable || loading}
|
||||
className="flex-1 h-11 rounded-md bg-foreground text-background text-sm font-medium hover:bg-foreground/90 disabled:opacity-50 transition-colors flex items-center justify-center gap-x-2"
|
||||
>
|
||||
{loading && <Loader size={16} />}
|
||||
{isAvailable ? addToCartLabel : 'Out of Stock'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sends the shopper to the Shopify checkout, where Shop Pay is offered. */}
|
||||
{handleBuyNow && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBuyNow}
|
||||
disabled={!isAvailable || buyingNow}
|
||||
className="mt-3 flex h-11 w-full cursor-pointer items-center justify-center rounded-md bg-shop px-4 text-white transition-colors hover:bg-shop/85 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<span className="sr-only">Buy with</span>
|
||||
{buyingNow ? <Loader size={16} /> : <ShopPayLogo />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
<div className="prose text-muted-foreground mb-10 text-[15px] leading-relaxed max-w-prose">
|
||||
{(product.descriptionHtml || product.description) && (
|
||||
<div className="mt-10 text-sm leading-6 text-foreground product-description">
|
||||
{product.descriptionHtml ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
||||
@@ -135,104 +248,6 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Options */}
|
||||
{product.options &&
|
||||
product.options.map((option) => (
|
||||
<div key={option.id} className="mb-8">
|
||||
<div className="text-xs uppercase font-mono tracking-widest text-muted-foreground mb-3">
|
||||
{option.name}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{option.values.map((value) => (
|
||||
<Button
|
||||
key={value}
|
||||
onClick={() => onOptionChange(option.name, value)}
|
||||
variant={
|
||||
selectedOptions[option.name] === value
|
||||
? 'default'
|
||||
: 'outline'
|
||||
}
|
||||
className={`rounded-2xl px-6 py-2.5 text-sm font-medium transition-all ${
|
||||
selectedOptions[option.name] === value ? 'shadow-md' : ''
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="mb-8">
|
||||
<div className="text-xs uppercase font-mono tracking-widest text-muted-foreground mb-3">
|
||||
QUANTITY
|
||||
</div>
|
||||
<div className="inline-flex items-center border border-border rounded-2xl">
|
||||
<Button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-l-2xl h-12 w-12"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<div className="px-8 font-mono text-lg font-semibold tabular-nums">
|
||||
{quantity}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-r-2xl h-12 w-12"
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!selectedVariant?.availableForSale || loading}
|
||||
size="lg"
|
||||
className="w-full h-16 text-base font-medium tracking-wider rounded-3xl btn-modern"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader size={18} className="mr-3" />
|
||||
ADDING TO BAG...
|
||||
</>
|
||||
) : selectedVariant?.availableForSale ? (
|
||||
String(addToCartLabel ?? 'Add to Cart').toUpperCase()
|
||||
) : (
|
||||
'OUT OF STOCK'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Features */}
|
||||
{features.length > 0 && (
|
||||
<div className="mt-12 pt-10 border-t border-border">
|
||||
<div className="space-y-6">
|
||||
{features.map((feature, index) => (
|
||||
<div key={index} className="flex gap-x-4 text-sm">
|
||||
<div className="mt-0.5 text-primary">
|
||||
<RemixIcon name={feature.icon} size={18} />
|
||||
</div>
|
||||
<div className="text-muted-foreground leading-snug">
|
||||
{feature.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12 text-[10px] font-mono text-center text-muted-foreground tracking-widest">
|
||||
FREE SHIPPING • EASY RETURNS • SECURE CHECKOUT
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,11 +11,13 @@ import ProductCard from './product-card';
|
||||
interface ProductRecommendationsProps {
|
||||
productId?: string;
|
||||
title?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
|
||||
productId: productIdProp,
|
||||
title = 'You Might Also Like These Products 2',
|
||||
title = 'You May Also Like',
|
||||
limit = 4,
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const handle = params?.handle as string | undefined;
|
||||
@@ -31,45 +33,40 @@ const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-12 text-gray-900 font-heading">
|
||||
<section className="bg-background py-16">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<h2 className="text-2xl md:text-3xl font-normal text-foreground mb-8">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-lg shadow-md overflow-hidden animate-pulse"
|
||||
>
|
||||
<div className="aspect-square bg-gray-200"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-6 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-8 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="h-12 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">Recommendations could not be loaded</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Recommendations could not be loaded
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{recommendations.slice(0, 4).map((recommendedProduct) => (
|
||||
<ProductCard
|
||||
key={recommendedProduct.id}
|
||||
product={recommendedProduct}
|
||||
/>
|
||||
))}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12">
|
||||
{loading
|
||||
? Array.from({ length: limit }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="aspect-square bg-zinc-100"></div>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="h-4 bg-zinc-200 w-4/5"></div>
|
||||
<div className="h-4 bg-zinc-200 w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: recommendations
|
||||
.slice(0, limit)
|
||||
.map((recommendedProduct) => (
|
||||
<ProductCard
|
||||
key={recommendedProduct.id}
|
||||
product={recommendedProduct}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -48,12 +48,14 @@ interface Product {
|
||||
|
||||
interface ProductsProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
limit?: number;
|
||||
showLoadMore?: boolean;
|
||||
}
|
||||
|
||||
const Products: React.FC<ProductsProps> = ({
|
||||
title = 'Our Products',
|
||||
title = 'Shopify Hydrogen Storefront',
|
||||
subtitle = 'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
|
||||
limit = 12,
|
||||
showLoadMore = true,
|
||||
}) => {
|
||||
@@ -109,10 +111,6 @@ const Products: React.FC<ProductsProps> = ({
|
||||
fetchProducts();
|
||||
}, [limit]);
|
||||
|
||||
const handleAddToCart = async (product: Product) => {
|
||||
console.log('Adding to cart:', product);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!loadingMore && hasMoreProducts) {
|
||||
fetchProducts(products, true);
|
||||
@@ -123,32 +121,20 @@ const Products: React.FC<ProductsProps> = ({
|
||||
return (
|
||||
<div className="py-20">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="inline px-6 py-2 bg-white border rounded-3xl text-xs font-mono tracking-widest text-muted-foreground">
|
||||
DISCOVER
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-center text-6xl font-bold tracking-tighter font-heading mb-5 text-foreground">
|
||||
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16">
|
||||
Loading our finest selection...
|
||||
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white rounded-3xl overflow-hidden animate-pulse border border-border h-[460px]"
|
||||
>
|
||||
<div className="h-[280px] bg-zinc-100"></div>
|
||||
<div className="p-8 space-y-6">
|
||||
<div className="h-6 bg-zinc-200 rounded-full w-4/5"></div>
|
||||
<div className="h-4 bg-zinc-200 rounded w-1/3"></div>
|
||||
<div className="pt-4 flex gap-3">
|
||||
<div className="h-11 flex-1 bg-zinc-200 rounded-2xl"></div>
|
||||
<div className="h-11 flex-1 bg-zinc-200 rounded-2xl"></div>
|
||||
</div>
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="aspect-square bg-zinc-100"></div>
|
||||
<div className="pt-4 space-y-2">
|
||||
<div className="h-4 bg-zinc-200 w-4/5"></div>
|
||||
<div className="h-4 bg-zinc-200 w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -162,9 +148,12 @@ const Products: React.FC<ProductsProps> = ({
|
||||
return (
|
||||
<div className="py-20 bg-zinc-50">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 text-center">
|
||||
<h2 className="text-6xl font-bold tracking-tighter font-heading mb-8 text-foreground">
|
||||
<h2 className="text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12">
|
||||
{subtitle}
|
||||
</p>
|
||||
<div className="mx-auto max-w-sm bg-white border border-border rounded-3xl p-12">
|
||||
<i className="ri-inbox-line text-6xl text-muted-foreground mb-6 block"></i>
|
||||
<h3 className="font-semibold text-2xl mb-3 tracking-tight">
|
||||
@@ -188,26 +177,16 @@ const Products: React.FC<ProductsProps> = ({
|
||||
return (
|
||||
<div className="py-20 bg-white">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="inline px-6 py-2 bg-zinc-100 rounded-3xl text-xs font-mono tracking-[1.5px] text-muted-foreground">
|
||||
CURATED SELECTION
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-center text-6xl font-bold tracking-tighter font-heading mb-5 text-foreground">
|
||||
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-center text-muted-foreground max-w-md mx-auto mb-16 text-lg">
|
||||
Beautifully designed objects for everyday life
|
||||
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-x-8 gap-y-16 mb-20">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={handleAddToCart}
|
||||
/>
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/client';
|
||||
|
||||
const SHOP_JS_URL =
|
||||
'https://cdn.shopify.com/shopifycloud/shop-js/modules/v2/loader.pay-button.esm.js';
|
||||
const TAG_NAME = 'shop-pay-button';
|
||||
// The hosted element renders at ~43px; reserve it so nothing shifts while shop-js loads.
|
||||
const MIN_HEIGHT = '43px';
|
||||
const LOAD_TIMEOUT_MS = 10000;
|
||||
|
||||
export interface ShopPayVariant {
|
||||
id: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
interface ShopPayButtonProps {
|
||||
variants: ShopPayVariant[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
width?: string;
|
||||
borderRadius?: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
let shopJsPromise: Promise<void> | null = null;
|
||||
|
||||
function loadShopJs(): Promise<void> {
|
||||
if (shopJsPromise) return shopJsPromise;
|
||||
|
||||
shopJsPromise = new Promise<void>((resolve, reject) => {
|
||||
if (!document.querySelector(`script[src="${SHOP_JS_URL}"]`)) {
|
||||
const script = document.createElement('script');
|
||||
script.src = SHOP_JS_URL;
|
||||
script.type = 'module';
|
||||
script.addEventListener('error', () =>
|
||||
reject(new Error('Failed to load the Shop Pay script.'))
|
||||
);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
// Element registration, not script load, is the real readiness signal.
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error('Timed out waiting for the Shop Pay button.')),
|
||||
LOAD_TIMEOUT_MS
|
||||
);
|
||||
customElements.whenDefined(TAG_NAME).then(() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}, reject);
|
||||
});
|
||||
|
||||
return shopJsPromise;
|
||||
}
|
||||
|
||||
// Storefront API IDs arrive as GIDs; the web component wants the bare numeric ID.
|
||||
function toNumericVariantId(id: string): string | null {
|
||||
const trimmed = id.trim();
|
||||
const gid = trimmed.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)/);
|
||||
if (gid) return gid[1];
|
||||
return /^\d+$/.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function toVariantsAttribute(variants: ShopPayVariant[]): string | null {
|
||||
if (variants.length === 0) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const { id, quantity = 1 } of variants) {
|
||||
const numericId = toNumericVariantId(id);
|
||||
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
|
||||
parts.push(`${numericId}:${quantity}`);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
function toStoreUrl(domain?: string): string | null {
|
||||
if (!domain) return null;
|
||||
try {
|
||||
return new URL(domain.startsWith('http') ? domain : `https://${domain}`)
|
||||
.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ShopPayButton: React.FC<ShopPayButtonProps> = ({
|
||||
variants,
|
||||
disabled = false,
|
||||
className,
|
||||
width = '100%',
|
||||
borderRadius = '8px',
|
||||
fallback = null,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>(
|
||||
'loading'
|
||||
);
|
||||
|
||||
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
|
||||
const variantsAttribute = toVariantsAttribute(variants);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeUrl || !variantsAttribute) return;
|
||||
|
||||
let cancelled = false;
|
||||
loadShopJs().then(
|
||||
() => !cancelled && setStatus('ready'),
|
||||
() => !cancelled && setStatus('error')
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [storeUrl, variantsAttribute]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (status !== 'ready' || !container || !storeUrl || !variantsAttribute) {
|
||||
return;
|
||||
}
|
||||
|
||||
const button = document.createElement(TAG_NAME);
|
||||
button.setAttribute('store-url', storeUrl);
|
||||
button.setAttribute('variants', variantsAttribute);
|
||||
button.setAttribute('channel', 'headless');
|
||||
if (disabled) button.setAttribute('disabled', '');
|
||||
button.style.setProperty('--shop-pay-button-width', width);
|
||||
button.style.setProperty('--shop-pay-button-border-radius', borderRadius);
|
||||
container.appendChild(button);
|
||||
|
||||
return () => {
|
||||
button.remove();
|
||||
};
|
||||
}, [status, storeUrl, variantsAttribute, disabled, width, borderRadius]);
|
||||
|
||||
if (!storeUrl || !variantsAttribute || status === 'error') {
|
||||
return <>{fallback}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ minHeight: MIN_HEIGHT }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopPayButton;
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
// "Buy with shop" lockup — the wordmark and the Shop mark are both in the path
|
||||
// data, so the button needs no additional text beyond a screen-reader label.
|
||||
const ShopPayLogo: React.FC<{ className?: string }> = ({
|
||||
className = 'h-auto w-[98px]',
|
||||
}) => (
|
||||
<svg
|
||||
fill="none"
|
||||
viewBox="0 0 10885 2079"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M158.355 1621V448.811H637.207C856.681 448.811 994.683 565.198 994.683 748.093C994.683 874.457 923.188 967.567 800.15 1004.15V1010.8C943.14 1039.06 1024.61 1145.47 1024.61 1296.78C1024.61 1494.64 884.946 1621 665.473 1621H158.355ZM630.556 1459.72C745.281 1459.72 813.451 1391.55 813.451 1278.49C813.451 1162.1 743.619 1093.93 630.556 1093.93H362.865V1459.72H630.556ZM605.616 939.301C713.69 939.301 781.86 874.457 781.86 774.696C781.86 671.61 713.69 610.091 605.616 610.091H362.865V939.301H605.616ZM1486.02 1645.94C1328.06 1645.94 1200.04 1547.84 1200.04 1323.38V764.72H1394.57V1290.13C1394.57 1411.5 1456.09 1479.67 1557.51 1479.67C1675.56 1479.67 1742.07 1394.88 1742.07 1268.51V764.72H1938.27V1621H1750.38V1501.29H1742.07C1693.85 1599.39 1604.07 1645.94 1486.02 1645.94ZM2229.79 1895.34L2392.74 1537.87L2053.55 764.72H2271.36L2430.98 1167.09C2455.92 1233.6 2474.21 1288.46 2492.5 1356.63H2499.15C2515.78 1290.13 2534.06 1231.93 2557.34 1167.09L2716.96 764.72H2934.77L2445.94 1895.34H2229.79ZM3535.97 1621L3266.62 764.72H3472.79L3585.85 1185.38C3607.47 1266.85 3624.09 1345 3639.06 1429.79H3645.71C3662.34 1343.33 3677.3 1271.84 3702.24 1185.38L3820.29 764.72H4021.47L4139.53 1185.38C4164.47 1273.5 4181.09 1348.32 4196.06 1429.79H4204.37C4217.67 1346.66 4234.3 1270.17 4255.91 1185.38L4368.97 764.72H4576.81L4305.79 1621H4104.61L3984.9 1195.35C3958.29 1102.24 3941.67 1029.09 3925.04 942.627H3918.39C3900.1 1030.75 3883.47 1103.91 3856.87 1195.35L3738.82 1621H3535.97ZM4696.08 1621V764.72H4890.61V1621H4696.08ZM4794.18 633.368C4724.35 633.368 4672.8 578.5 4672.8 510.33C4672.8 442.16 4726.01 388.954 4794.18 388.954C4864.01 388.954 4917.22 442.16 4917.22 510.33C4917.22 580.162 4864.01 633.368 4794.18 633.368ZM5389.5 1637.63C5249.83 1637.63 5163.37 1572.78 5163.37 1426.47V926H5025.37V776.359H5111.83C5160.05 776.359 5176.67 758.069 5176.67 709.851V560.21H5359.57V764.72H5520.85V926H5359.57V1363.28C5359.57 1433.12 5384.51 1461.38 5441.04 1461.38C5465.98 1461.38 5489.26 1458.06 5520.85 1451.41V1616.01C5474.29 1630.98 5437.71 1637.63 5389.5 1637.63ZM5694.66 1621V418.883H5890.86V877.782H5897.51C5947.39 784.672 6040.5 739.78 6153.56 739.78C6316.51 739.78 6444.53 837.878 6444.53 1062.34V1621H6248.34V1095.59C6248.34 974.218 6186.82 906.048 6080.4 906.048C5959.03 906.048 5889.2 990.844 5889.2 1117.21V1621H5694.66Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<g clipPath="url(#shop-pay-logo-clip)">
|
||||
<path
|
||||
d="M7406 1027.33C7247.3 992.071 7176.6 978.274 7176.6 915.639C7176.6 856.727 7224.44 827.38 7320.13 827.38C7404.29 827.38 7465.8 865.049 7511.08 938.853C7514.5 944.547 7521.55 946.518 7527.32 943.452L7705.88 851.032C7712.29 847.747 7714.64 839.425 7711 833.074C7636.89 701.453 7499.98 629.4 7319.71 629.4C7082.83 629.4 6935.67 748.976 6935.67 939.072C6935.67 1140.99 7114.87 1192.02 7273.78 1227.28C7432.7 1262.54 7503.61 1276.34 7503.61 1338.97C7503.61 1401.61 7451.92 1431.17 7348.75 1431.17C7253.49 1431.17 7182.79 1386.5 7140.08 1299.77C7136.87 1293.42 7129.4 1290.79 7123.2 1294.08L6945.07 1384.53C6938.87 1387.81 6936.31 1395.48 6939.51 1402.05C7010.21 1547.68 7155.24 1629.59 7348.97 1629.59C7595.67 1629.59 7744.75 1511.99 7744.75 1315.98C7744.75 1119.97 7564.69 1063.03 7406 1027.77V1027.33Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8362.88 629.4C8261.64 629.4 8172.15 666.193 8107.86 731.675C8103.8 735.617 8097.18 732.77 8097.18 727.076V308.997C8097.18 301.77 8091.62 296.076 8084.57 296.076H7861.16C7854.11 296.076 7848.56 301.77 7848.56 308.997V1606.6C7848.56 1613.82 7854.11 1619.52 7861.16 1619.52H8084.57C8091.62 1619.52 8097.18 1613.82 8097.18 1606.6V1037.41C8097.18 927.465 8179.41 843.148 8290.26 843.148C8401.12 843.148 8481.43 925.713 8481.43 1037.41V1606.6C8481.43 1613.82 8486.98 1619.52 8494.03 1619.52H8717.44C8724.49 1619.52 8730.05 1613.82 8730.05 1606.6V1037.41C8730.05 798.253 8577.11 629.4 8362.88 629.4Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9183.28 592.17C9061.97 592.17 8948.34 630.276 8866.74 685.246C8861.19 688.969 8859.27 696.634 8862.69 702.548L8961.15 874.904C8964.78 881.036 8972.47 883.226 8978.45 879.503C9040.39 841.177 9111.3 821.248 9183.71 821.686C9378.72 821.686 9522.04 962.725 9522.04 1149.1C9522.04 1307.88 9407.34 1425.48 9261.89 1425.48C9143.34 1425.48 9061.11 1354.74 9061.11 1254.88C9061.11 1197.72 9084.82 1150.85 9146.55 1117.78C9152.95 1114.28 9155.3 1106.17 9151.46 1099.82L9058.55 938.634C9055.56 933.378 9049.15 930.969 9043.38 933.159C8918.86 980.464 8831.5 1094.35 8831.5 1247.21C8831.5 1478.48 9011.13 1651.05 9261.67 1651.05C9554.29 1651.05 9764.68 1443.22 9764.68 1145.16C9764.68 825.628 9519.9 592.17 9183.28 592.17Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M10418.3 627.429C10305.3 627.429 10204.2 670.354 10130.6 745.692C10126.5 749.853 10119.9 746.787 10119.9 741.092V650.425C10119.9 643.198 10114.3 637.504 10107.3 637.504H9889.63C9882.58 637.504 9877.03 643.198 9877.03 650.425V1946.05C9877.03 1953.28 9882.58 1958.97 9889.63 1958.97H10113C10120.1 1958.97 10125.6 1953.28 10125.6 1946.05V1521.19C10125.6 1515.49 10132.3 1512.64 10136.3 1516.37C10209.8 1586.45 10307 1627.4 10418.3 1627.4C10680.3 1627.4 10884.7 1409.93 10884.7 1127.42C10884.7 844.9 10680.1 627.429 10418.3 627.429ZM10376 1407.96C10226.9 1407.96 10113.9 1286.41 10113.9 1125.66C10113.9 964.915 10226.7 843.367 10376 843.367C10525.3 843.367 10637.8 962.944 10637.8 1125.66C10637.8 1288.38 10526.8 1407.96 10375.8 1407.96H10376Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="shop-pay-logo-clip">
|
||||
<rect
|
||||
width="3948.86"
|
||||
height="1662.68"
|
||||
fill="white"
|
||||
transform="translate(6935.67 296.076)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ShopPayLogo;
|
||||
@@ -0,0 +1,51 @@
|
||||
// Swatch configuration for product option pickers.
|
||||
//
|
||||
// Shopify exposes a `swatch` on each option value, but plenty of products ship
|
||||
// without one configured. These settings decide which options render as colour
|
||||
// circles and what colour to use when the API doesn't supply one.
|
||||
|
||||
// Option names (case-insensitive) rendered as colour circles instead of pills.
|
||||
export const SWATCH_OPTION_NAMES = ['color', 'colour'];
|
||||
|
||||
// Fallback colours, keyed by lowercased option value name. The first group
|
||||
// mirrors hexes the store defines on other products, so a product missing its
|
||||
// swatches still matches the ones that have them. Add your own names here.
|
||||
export const SWATCH_COLORS: Record<string, string> = {
|
||||
clay: '#5a4b3c',
|
||||
green: '#81a69b',
|
||||
ocean: '#768da0',
|
||||
olive: '#7f8060',
|
||||
purple: '#766589',
|
||||
red: '#a88084',
|
||||
|
||||
beige: '#e8dcc8',
|
||||
black: '#000000',
|
||||
blue: '#2563eb',
|
||||
brown: '#7c5c40',
|
||||
burgundy: '#6d2532',
|
||||
charcoal: '#36393d',
|
||||
cream: '#f3ead8',
|
||||
gold: '#c9a227',
|
||||
gray: '#8a8d91',
|
||||
grey: '#8a8d91',
|
||||
ivory: '#f6f2e6',
|
||||
khaki: '#a89d78',
|
||||
lavender: '#b9a8d0',
|
||||
mint: '#a8d5ba',
|
||||
navy: '#1f2a44',
|
||||
orange: '#e2733a',
|
||||
pink: '#e3a7b5',
|
||||
sand: '#d9c9a8',
|
||||
silver: '#c4c6c8',
|
||||
stone: '#a9a294',
|
||||
tan: '#c8a882',
|
||||
teal: '#3f8f8b',
|
||||
white: '#ffffff',
|
||||
yellow: '#e5c34a',
|
||||
};
|
||||
|
||||
export const swatchColorForName = (name: string): string | undefined =>
|
||||
SWATCH_COLORS[name.trim().toLowerCase()];
|
||||
|
||||
export const isSwatchOptionName = (name: string): boolean =>
|
||||
SWATCH_OPTION_NAMES.includes(name.trim().toLowerCase());
|
||||
@@ -0,0 +1,43 @@
|
||||
// Shop policies are exposed on the `shop` object of the Storefront API.
|
||||
// There is no lookup-by-handle field, so we fetch all of them and match.
|
||||
export const GET_SHOP_POLICIES_QUERY = `
|
||||
query GetShopPolicies {
|
||||
shop {
|
||||
privacyPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
body
|
||||
url
|
||||
}
|
||||
termsOfService {
|
||||
id
|
||||
title
|
||||
handle
|
||||
body
|
||||
url
|
||||
}
|
||||
refundPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
body
|
||||
url
|
||||
}
|
||||
shippingPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
body
|
||||
url
|
||||
}
|
||||
subscriptionPolicy {
|
||||
id
|
||||
title
|
||||
handle
|
||||
body
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -74,6 +74,28 @@ export const ProductFragment = `
|
||||
id
|
||||
name
|
||||
values
|
||||
optionValues {
|
||||
id
|
||||
name
|
||||
swatch {
|
||||
color
|
||||
image {
|
||||
previewImage {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
firstSelectableVariant {
|
||||
id
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
width
|
||||
height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { SHOPIFY_STOREFRONT_API_URL } from '@/services/shopify/client';
|
||||
import { GET_SHOP_POLICIES_QUERY } from '@/graphql/policies';
|
||||
|
||||
export interface ShopPolicy {
|
||||
id: string;
|
||||
title: string;
|
||||
handle: string;
|
||||
body: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface ShopPoliciesResponse {
|
||||
data?: {
|
||||
shop?: Record<string, ShopPolicy | null>;
|
||||
};
|
||||
}
|
||||
|
||||
// Handles Shopify uses for each policy — also the routes under /policies/[handle].
|
||||
export const POLICY_HANDLES = [
|
||||
'terms-of-service',
|
||||
'privacy-policy',
|
||||
'refund-policy',
|
||||
'shipping-policy',
|
||||
'subscription-policy',
|
||||
] as const;
|
||||
|
||||
// Policies change rarely, so this uses its own fetch (revalidated hourly)
|
||||
// rather than the no-store `shopifyFetch` used for carts and products.
|
||||
export async function getShopPolicies(): Promise<ShopPolicy[]> {
|
||||
const token = process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN;
|
||||
|
||||
const response = await fetch(SHOPIFY_STOREFRONT_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'X-Shopify-Storefront-Access-Token': token } : {}),
|
||||
},
|
||||
body: JSON.stringify({ query: GET_SHOP_POLICIES_QUERY }),
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Failed to load shop policies:', response.status);
|
||||
return [];
|
||||
}
|
||||
|
||||
const json: ShopPoliciesResponse = await response.json();
|
||||
const shop = json.data?.shop ?? {};
|
||||
|
||||
return Object.values(shop).filter((policy): policy is ShopPolicy =>
|
||||
Boolean(policy?.handle)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getShopPolicy(
|
||||
handle: string
|
||||
): Promise<ShopPolicy | null> {
|
||||
const policies = await getShopPolicies();
|
||||
return policies.find((policy) => policy.handle === handle) ?? null;
|
||||
}
|
||||
@@ -30,10 +30,28 @@ interface ProductVariant {
|
||||
image?: ProductImage;
|
||||
}
|
||||
|
||||
interface ProductOption {
|
||||
export interface ProductOptionValue {
|
||||
id: string;
|
||||
name: string;
|
||||
swatch?: {
|
||||
color?: string | null;
|
||||
image?: {
|
||||
previewImage?: {
|
||||
url: string;
|
||||
} | null;
|
||||
} | null;
|
||||
} | null;
|
||||
firstSelectableVariant?: {
|
||||
id: string;
|
||||
image?: ProductImage | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ProductOption {
|
||||
id: string;
|
||||
name: string;
|
||||
values: string[];
|
||||
optionValues?: ProductOptionValue[];
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
|
||||
Reference in New Issue
Block a user