Add React editor project
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
|
||||
export interface AccountFormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: 'text' | 'email' | 'password';
|
||||
autoComplete?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
interface AccountFormProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
fields: AccountFormField[];
|
||||
submitLabel: string;
|
||||
endpoint: string;
|
||||
/** Merged into the request body alongside the field values. */
|
||||
extraPayload?: Record<string, string>;
|
||||
/** Where to go on success; omit to show `successMessage` instead. */
|
||||
redirectTo?: string;
|
||||
successMessage?: string;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AccountForm: React.FC<AccountFormProps> = ({
|
||||
title,
|
||||
description,
|
||||
fields,
|
||||
submitLabel,
|
||||
endpoint,
|
||||
extraPayload,
|
||||
redirectTo,
|
||||
successMessage,
|
||||
footer,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...values, ...extraPayload }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
setError(data.error ?? 'Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirectTo) {
|
||||
// refresh() so server components re-read the new session cookie.
|
||||
router.push(redirectTo);
|
||||
router.refresh();
|
||||
} else {
|
||||
setDone(true);
|
||||
}
|
||||
} catch {
|
||||
setError('Could not reach the server. Please try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
<h1 className="text-2xl font-normal text-foreground">{title}</h1>
|
||||
{description && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
|
||||
{done && successMessage ? (
|
||||
<p className="mt-6 text-sm text-foreground">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
|
||||
{fields.map((field) => (
|
||||
<label key={field.name} className="flex flex-col gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
<input
|
||||
type={field.type ?? 'text'}
|
||||
name={field.name}
|
||||
required={field.required ?? true}
|
||||
autoComplete={field.autoComplete}
|
||||
value={values[field.name] ?? ''}
|
||||
onChange={(event) =>
|
||||
setValues((prev) => ({
|
||||
...prev,
|
||||
[field.name]: event.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-11 rounded-md border border-border px-3 text-sm outline-none transition-colors focus:border-foreground"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={submitting} className="h-11">
|
||||
{submitting && <Loader size={16} />}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{footer && (
|
||||
<div className="mt-6 flex flex-col gap-2 text-sm text-muted-foreground">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountFormLink: React.FC<{ href: string; children: React.ReactNode }> = ({
|
||||
href,
|
||||
children,
|
||||
}) => (
|
||||
<Link href={href} className="underline underline-offset-2 hover:text-foreground">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
export default AccountForm;
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RiUserLine } from '@remixicon/react';
|
||||
|
||||
interface SessionCustomer {
|
||||
displayName: string;
|
||||
email: string;
|
||||
firstName?: string | null;
|
||||
}
|
||||
|
||||
const AccountMenu: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [customer, setCustomer] = useState<SessionCustomer | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// The token lives in an httpOnly cookie, so the signed-in state has to come
|
||||
// from the server rather than being read directly.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch('/api/account/me')
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (!cancelled) setCustomer(data.customer ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCustomer(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setOpen(false);
|
||||
await fetch('/api/account/logout', { method: 'POST' });
|
||||
setCustomer(null);
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
// Signed out: straight to the sign-in page, no menu.
|
||||
if (!customer) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Sign in"
|
||||
className="rounded-full"
|
||||
>
|
||||
<Link href="/account/login">
|
||||
<RiUserLine className="size-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Account menu"
|
||||
aria-expanded={open}
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiUserLine className="size-5" />
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-md border border-border bg-background py-1 shadow-md">
|
||||
<div className="px-4 py-2">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{customer.firstName || customer.displayName}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{customer.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="my-1 h-px bg-border" />
|
||||
|
||||
<Link
|
||||
href="/account"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-4 py-2 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
Order history
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="block w-full px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountMenu;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { Receipt } from 'lucide-react';
|
||||
import AccountOrders, {
|
||||
type AccountOrdersProps,
|
||||
} from '@/components/shopify/account-orders';
|
||||
|
||||
const accountOrdersEditor: ComponentConfig<AccountOrdersProps> = {
|
||||
label: 'Order history',
|
||||
icon: <Receipt size={16} />,
|
||||
category: 'account',
|
||||
defaultProps: {
|
||||
title: 'Order history',
|
||||
signedOutMessage: 'Sign in to see your orders.',
|
||||
emptyMessage: "You haven't placed any orders yet.",
|
||||
limit: 20,
|
||||
},
|
||||
fields: {
|
||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||
signedOutMessage: {
|
||||
label: 'Signed-out message',
|
||||
type: 'text',
|
||||
contentEditable: true,
|
||||
},
|
||||
emptyMessage: {
|
||||
label: 'Empty message',
|
||||
type: 'text',
|
||||
contentEditable: true,
|
||||
},
|
||||
limit: { label: 'Orders shown', type: 'number', min: 1, max: 50 },
|
||||
},
|
||||
render: (props) => <AccountOrders {...props} />,
|
||||
};
|
||||
|
||||
export default accountOrdersEditor;
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import OrderHistory from '@/components/shopify/order-history';
|
||||
import type { Customer } from '@/services/shopify/customer';
|
||||
|
||||
export interface AccountOrdersProps {
|
||||
title?: string;
|
||||
/** Shown while signed out — the route guard normally redirects first. */
|
||||
signedOutMessage?: string;
|
||||
emptyMessage?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side wrapper so order history can live in a page.json alongside the
|
||||
* other blocks. `/account/page.tsx` still guards the route server-side; this
|
||||
* re-reads the session through `/api/account/orders` so the block works the
|
||||
* same whether it is rendered by the page or previewed in the editor.
|
||||
*/
|
||||
const AccountOrders: React.FC<AccountOrdersProps> = ({
|
||||
title = 'Order history',
|
||||
signedOutMessage = 'Sign in to see your orders.',
|
||||
emptyMessage = "You haven't placed any orders yet.",
|
||||
limit = 20,
|
||||
}) => {
|
||||
const [customer, setCustomer] = useState<Customer | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
fetch(`/api/account/orders?orders=${limit}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (!cancelled) setCustomer(data.customer ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCustomer(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [limit]);
|
||||
|
||||
const orderCount = customer?.orders?.edges.length ?? 0;
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-12">
|
||||
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{customer && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{customer.displayName} · {customer.email}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-10">
|
||||
{loading ? (
|
||||
<div className="animate-pulse space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-20 bg-zinc-100" />
|
||||
))}
|
||||
</div>
|
||||
) : !customer ? (
|
||||
<p className="text-sm text-muted-foreground">{signedOutMessage}</p>
|
||||
) : orderCount === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
|
||||
) : (
|
||||
<OrderHistory customer={customer} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountOrders;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { KeyRound, LogIn, UserPlus } from 'lucide-react';
|
||||
import AccountPanel, {
|
||||
type AccountFlow,
|
||||
type AccountPanelProps,
|
||||
} from '@/components/shopify/account-panel';
|
||||
|
||||
/**
|
||||
* One component, five registered blocks. `flow` decides the field list and the
|
||||
* API endpoint, so it lives in `defaultProps` and is deliberately absent from
|
||||
* `fields` — it is behaviour, not content. Everything editable here is copy.
|
||||
*/
|
||||
const accountBlock = (
|
||||
flow: AccountFlow,
|
||||
label: string,
|
||||
icon: React.ReactNode,
|
||||
defaults: Omit<AccountPanelProps, 'flow'>
|
||||
): ComponentConfig<AccountPanelProps> => ({
|
||||
label,
|
||||
icon,
|
||||
category: 'account',
|
||||
defaultProps: { flow, ...defaults },
|
||||
fields: {
|
||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||
description: {
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
contentEditable: true,
|
||||
},
|
||||
submitLabel: { label: 'Button label', type: 'text', contentEditable: true },
|
||||
successMessage: {
|
||||
label: 'Success message',
|
||||
type: 'textarea',
|
||||
contentEditable: true,
|
||||
},
|
||||
links: {
|
||||
label: 'Footer links',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: 'Link', url: '/account/login' },
|
||||
getItemSummary: (item) => item?.label || 'Link',
|
||||
arrayFields: {
|
||||
label: { label: 'Label', type: 'text', contentEditable: true },
|
||||
url: { label: 'Link', type: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <AccountPanel {...props} />,
|
||||
});
|
||||
|
||||
export const accountLoginEditor = accountBlock(
|
||||
'login',
|
||||
'Sign in form',
|
||||
<LogIn size={16} />,
|
||||
{
|
||||
title: 'Sign in',
|
||||
description: '',
|
||||
submitLabel: 'Sign in',
|
||||
successMessage: '',
|
||||
links: [
|
||||
{ label: 'Create an account', url: '/account/register' },
|
||||
{ label: 'Forgot your password?', url: '/account/recover' },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
export const accountRegisterEditor = accountBlock(
|
||||
'register',
|
||||
'Register form',
|
||||
<UserPlus size={16} />,
|
||||
{
|
||||
title: 'Create account',
|
||||
description: '',
|
||||
submitLabel: 'Create account',
|
||||
successMessage: '',
|
||||
links: [{ label: 'Already have an account? Sign in', url: '/account/login' }],
|
||||
}
|
||||
);
|
||||
|
||||
export const accountRecoverEditor = accountBlock(
|
||||
'recover',
|
||||
'Password recovery form',
|
||||
<KeyRound size={16} />,
|
||||
{
|
||||
title: 'Reset password',
|
||||
description:
|
||||
"Enter your email and we'll send you a link to set a new password.",
|
||||
submitLabel: 'Send reset link',
|
||||
successMessage:
|
||||
'If that email has an account, a reset link is on its way.',
|
||||
links: [{ label: 'Back to sign in', url: '/account/login' }],
|
||||
}
|
||||
);
|
||||
|
||||
export const accountResetEditor = accountBlock(
|
||||
'reset',
|
||||
'Set password form',
|
||||
<KeyRound size={16} />,
|
||||
{
|
||||
title: 'Set a new password',
|
||||
description: '',
|
||||
submitLabel: 'Save password',
|
||||
successMessage: '',
|
||||
links: [],
|
||||
}
|
||||
);
|
||||
|
||||
export const accountActivateEditor = accountBlock(
|
||||
'activate',
|
||||
'Activate account form',
|
||||
<KeyRound size={16} />,
|
||||
{
|
||||
title: 'Activate your account',
|
||||
description: 'Choose a password to finish setting up your account.',
|
||||
submitLabel: 'Activate account',
|
||||
successMessage: '',
|
||||
links: [],
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import AccountForm, {
|
||||
AccountFormLink,
|
||||
type AccountFormField,
|
||||
} from '@/components/shopify/account-form';
|
||||
|
||||
/**
|
||||
* The account routes are all the same form with a different field list and a
|
||||
* different endpoint. Those two things are behaviour, not content, so they are
|
||||
* fixed here per flow and are deliberately *not* exposed as editor fields —
|
||||
* the editor only gets the wording (see `account-panel.editor.tsx`).
|
||||
*/
|
||||
export type AccountFlow =
|
||||
| 'login'
|
||||
| 'register'
|
||||
| 'recover'
|
||||
| 'reset'
|
||||
| 'activate';
|
||||
|
||||
const EMAIL: AccountFormField = {
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
autoComplete: 'email',
|
||||
};
|
||||
|
||||
const FLOWS: Record<
|
||||
AccountFlow,
|
||||
{
|
||||
fields: AccountFormField[];
|
||||
endpoint: string;
|
||||
redirectTo?: string;
|
||||
/** Reads Shopify's emailed `/{id}/{token}` link segments into the body. */
|
||||
usesRouteToken?: 'resetToken' | 'activationToken';
|
||||
}
|
||||
> = {
|
||||
login: {
|
||||
fields: [
|
||||
EMAIL,
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'current-password',
|
||||
},
|
||||
],
|
||||
endpoint: '/api/account/login',
|
||||
redirectTo: '/account',
|
||||
},
|
||||
register: {
|
||||
fields: [
|
||||
{
|
||||
name: 'firstName',
|
||||
label: 'First name',
|
||||
autoComplete: 'given-name',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
label: 'Last name',
|
||||
autoComplete: 'family-name',
|
||||
required: false,
|
||||
},
|
||||
EMAIL,
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
],
|
||||
endpoint: '/api/account/register',
|
||||
redirectTo: '/account',
|
||||
},
|
||||
recover: {
|
||||
fields: [EMAIL],
|
||||
endpoint: '/api/account/recover',
|
||||
},
|
||||
reset: {
|
||||
fields: [
|
||||
{
|
||||
name: 'password',
|
||||
label: 'New password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
],
|
||||
endpoint: '/api/account/reset',
|
||||
redirectTo: '/account',
|
||||
usesRouteToken: 'resetToken',
|
||||
},
|
||||
activate: {
|
||||
fields: [
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
autoComplete: 'new-password',
|
||||
},
|
||||
],
|
||||
endpoint: '/api/account/activate',
|
||||
redirectTo: '/account',
|
||||
usesRouteToken: 'activationToken',
|
||||
},
|
||||
};
|
||||
|
||||
export interface AccountPanelLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AccountPanelProps {
|
||||
flow?: AccountFlow;
|
||||
title?: string;
|
||||
description?: string;
|
||||
submitLabel?: string;
|
||||
successMessage?: string;
|
||||
/** Rendered under the form — "New here? Create an account", etc. */
|
||||
links?: AccountPanelLink[];
|
||||
}
|
||||
|
||||
const AccountPanel: React.FC<AccountPanelProps> = ({
|
||||
flow = 'login',
|
||||
title = 'Sign in',
|
||||
description,
|
||||
submitLabel = 'Sign in',
|
||||
successMessage,
|
||||
links = [],
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const config = FLOWS[flow] ?? FLOWS.login;
|
||||
|
||||
const extraPayload = config.usesRouteToken
|
||||
? {
|
||||
id: (params?.id as string) ?? '',
|
||||
[config.usesRouteToken]: (params?.token as string) ?? '',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<main className="max-w-screen-2xl mx-auto w-full px-8 py-16">
|
||||
<AccountForm
|
||||
title={title}
|
||||
description={description}
|
||||
fields={config.fields}
|
||||
submitLabel={submitLabel}
|
||||
endpoint={config.endpoint}
|
||||
extraPayload={extraPayload}
|
||||
redirectTo={config.redirectTo}
|
||||
successMessage={successMessage}
|
||||
footer={
|
||||
links.length > 0 ? (
|
||||
<>
|
||||
{links.map((link) => (
|
||||
<AccountFormLink key={link.url} href={link.url}>
|
||||
{link.label}
|
||||
</AccountFormLink>
|
||||
))}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountPanel;
|
||||
+312
-201
@@ -1,11 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useCartStore, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { X, ImageIcon, Minus, Plus } from 'lucide-react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetBody,
|
||||
AnimatePresence,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiImageLine,
|
||||
RiSubtractLine,
|
||||
RiAddLine,
|
||||
} from '@remixicon/react';
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
@@ -13,25 +26,43 @@ import {
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
} from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { isDefaultTitleSelection } from '@/services/shopify/catalog';
|
||||
|
||||
const CartDrawer: React.FC = () => {
|
||||
const {
|
||||
isOpen,
|
||||
closeCart,
|
||||
items,
|
||||
itemCount,
|
||||
totalAmount,
|
||||
checkoutUrl,
|
||||
loading,
|
||||
removeItem,
|
||||
updateItemQuantity,
|
||||
} = useShopifyCart();
|
||||
const isOpen = useCartStore((s) => s.isOpen);
|
||||
const closeCart = useCartStore((s) => s.closeCart);
|
||||
const loading = useCartStore((s) => s.loading);
|
||||
const cart = useCartStore((s) => s.cart);
|
||||
const removeItem = useCartStore((s) => s.removeItem);
|
||||
const updateItemQuantity = useCartStore((s) => s.updateItemQuantity);
|
||||
const applyDiscountCode = useCartStore((s) => s.applyDiscountCode);
|
||||
|
||||
const [discountCode, setDiscountCode] = useState('');
|
||||
const [discountError, setDiscountError] = useState<string | null>(null);
|
||||
const [applyingDiscount, setApplyingDiscount] = useState(false);
|
||||
// The store's `loading` flag is global, so track the specific line being
|
||||
// changed to keep the other rows interactive.
|
||||
const [pendingLineId, setPendingLineId] = useState<string | null>(null);
|
||||
|
||||
const runLineAction = async (lineId: string, action: () => Promise<unknown>) => {
|
||||
if (pendingLineId) return;
|
||||
|
||||
try {
|
||||
setPendingLineId(lineId);
|
||||
await action();
|
||||
} catch (err) {
|
||||
console.error('Cart line update failed:', err);
|
||||
} finally {
|
||||
setPendingLineId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const items = cart?.lines?.edges?.map((edge) => edge.node) ?? [];
|
||||
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
|
||||
const totalAmount = parseFloat(cart?.cost?.totalAmount?.amount ?? '0');
|
||||
const checkoutUrl = cart?.checkoutUrl ?? null;
|
||||
const appliedDiscounts =
|
||||
cart?.discountCodes?.filter((discount) => discount.applicable) ?? [];
|
||||
|
||||
const handleCheckout = () => {
|
||||
if (checkoutUrl) {
|
||||
@@ -39,200 +70,280 @@ const CartDrawer: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyDiscount = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const code = discountCode.trim();
|
||||
if (!code || applyingDiscount) return;
|
||||
|
||||
try {
|
||||
setApplyingDiscount(true);
|
||||
setDiscountError(null);
|
||||
const updatedCart = await applyDiscountCode(code);
|
||||
|
||||
// Shopify accepts unknown codes silently, flagging them as inapplicable.
|
||||
const accepted = updatedCart.discountCodes?.some(
|
||||
(discount) =>
|
||||
discount.applicable &&
|
||||
discount.code.toLowerCase() === code.toLowerCase()
|
||||
);
|
||||
|
||||
if (accepted) {
|
||||
setDiscountCode('');
|
||||
} else {
|
||||
setDiscountError('That code is not valid for this cart.');
|
||||
}
|
||||
} catch {
|
||||
setDiscountError('Could not apply that code. Please try again.');
|
||||
} finally {
|
||||
setApplyingDiscount(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getItemImage = (item: (typeof items)[0]) => {
|
||||
return item.merchandise.image?.url;
|
||||
};
|
||||
|
||||
const getSelectedOptions = (item: (typeof items)[0]) => {
|
||||
return item.merchandise.selectedOptions ?? [];
|
||||
// Single-SKU items carry a synthetic `Title: Default Title` — not worth a line.
|
||||
return (item.merchandise.selectedOptions ?? []).filter(
|
||||
(option) => !isDefaultTitleSelection(option)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(open) => !open && closeCart()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full max-w-md"
|
||||
showCloseButton={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<SheetHeader className="h-14 min-h-0 px-4 py-3 flex items-center border-b border-border">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<SheetTitle className="text-base">
|
||||
Shopping Cart ({itemCount})
|
||||
</SheetTitle>
|
||||
<Button onClick={closeCart} variant="ghost" size="icon-sm">
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Empty className="border-0">
|
||||
<EmptyContent>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Your cart is empty</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Add some products to get started!
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Sheet
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && closeCart()}
|
||||
side="right"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<SheetContent className="w-full max-w-md" showCloseButton={false}>
|
||||
{/* Header */}
|
||||
<SheetHeader className="min-h-0 px-5 py-4 border-b-0">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<SheetTitle className="text-base font-medium flex items-center gap-x-2">
|
||||
Cart
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
|
||||
{itemCount}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<Button
|
||||
onClick={closeCart}
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Close cart"
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Cart Items */}
|
||||
<SheetBody className="px-5">
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader size={20} />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Your cart is empty</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Add some products to get started!
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={closeCart} className="w-full">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{items.map((item) => {
|
||||
const image = getItemImage(item);
|
||||
const selectedOptions = getSelectedOptions(item);
|
||||
const isPending = pendingLineId === item.id;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex items-start gap-x-3">
|
||||
{/* Product Image */}
|
||||
<div className="w-16 h-16 bg-zinc-100 overflow-hidden shrink-0">
|
||||
{image ? (
|
||||
<Image
|
||||
src={image}
|
||||
alt={item.merchandise.product.title}
|
||||
width={64}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-400">
|
||||
<RiImageLine size={20} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-x-3">
|
||||
<h4 className="text-sm font-medium text-foreground line-clamp-2">
|
||||
{item.merchandise.product.title}
|
||||
</h4>
|
||||
<span className="shrink-0 font-mono tabular-nums tracking-tight text-sm text-foreground">
|
||||
$
|
||||
{parseFloat(
|
||||
item.cost?.totalAmount?.amount ??
|
||||
item.merchandise.price.amount
|
||||
).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{selectedOptions
|
||||
.map((option) => option.value)
|
||||
.join(' / ')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center gap-x-2 mt-2">
|
||||
<div className="inline-flex items-center rounded-full bg-secondary">
|
||||
<Button
|
||||
onClick={() =>
|
||||
runLineAction(item.id, () =>
|
||||
updateItemQuantity(
|
||||
item.id,
|
||||
item.quantity - 1
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={item.quantity <= 1 || isPending}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Decrease quantity"
|
||||
className="size-7 rounded-full"
|
||||
>
|
||||
<RiSubtractLine size={14} />
|
||||
</Button>
|
||||
<span className="min-w-8 px-1 text-sm tabular-nums text-center">
|
||||
{isPending ? (
|
||||
<Loader size={12} className="mx-auto" />
|
||||
) : (
|
||||
item.quantity
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() =>
|
||||
runLineAction(item.id, () =>
|
||||
updateItemQuantity(
|
||||
item.id,
|
||||
item.quantity + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
disabled={isPending}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Increase quantity"
|
||||
className="size-7 rounded-full"
|
||||
>
|
||||
<RiAddLine size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() =>
|
||||
runLineAction(item.id, () => removeItem(item.id))
|
||||
}
|
||||
disabled={isPending}
|
||||
variant="link"
|
||||
aria-label={`Remove ${item.merchandise.product.title}`}
|
||||
className="ml-auto h-7 self-center px-0 text-xs font-normal leading-none text-muted-foreground underline decoration-dashed underline-offset-2 hover:text-foreground hover:no-underline"
|
||||
>
|
||||
remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SheetBody>
|
||||
|
||||
{/* Footer — discount, total, checkout */}
|
||||
{items.length > 0 && (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
{/* Discount Code */}
|
||||
<form onSubmit={handleApplyDiscount} className="flex gap-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={discountCode}
|
||||
onChange={(event) => {
|
||||
setDiscountCode(event.target.value);
|
||||
setDiscountError(null);
|
||||
}}
|
||||
placeholder="Discount code"
|
||||
aria-label="Discount code"
|
||||
className="flex-1 h-10 rounded-md border border-border px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:border-foreground transition-colors"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!discountCode.trim() || applyingDiscount}
|
||||
className="h-10 bg-muted-foreground px-5 hover:bg-foreground"
|
||||
>
|
||||
{applyingDiscount ? <Loader size={16} /> : 'Apply'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{discountError && (
|
||||
<p className="text-xs text-destructive">{discountError}</p>
|
||||
)}
|
||||
|
||||
{appliedDiscounts.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applied: {appliedDiscounts.map((d) => d.code).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Estimated total */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="text-base text-foreground">
|
||||
Estimated total
|
||||
</span>
|
||||
<span className="font-mono tabular-nums tracking-tight text-lg text-foreground">
|
||||
${totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Taxes and shipping calculated at checkout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={!checkoutUrl || pendingLineId !== null}
|
||||
className="h-12 w-full"
|
||||
>
|
||||
Go to Checkout
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={closeCart}
|
||||
variant="ghost"
|
||||
className="w-full font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{items.map((item) => {
|
||||
const image = getItemImage(item);
|
||||
const selectedOptions = getSelectedOptions(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start space-x-4 pb-6 border-b border-border last:border-b-0"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="w-20 h-20 bg-muted rounded-lg overflow-hidden flex-shrink-0">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt={item.merchandise.product.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<ImageIcon size={24} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-foreground mb-1 line-clamp-2">
|
||||
{item.merchandise.product.title}
|
||||
</h4>
|
||||
|
||||
{/* Variant Info */}
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{selectedOptions.map((option, index) => (
|
||||
<span key={option.name}>
|
||||
{option.value}
|
||||
{index < selectedOptions.length - 1
|
||||
? ' / '
|
||||
: ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center mt-3">
|
||||
<div className="flex items-center border border-border rounded-lg">
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateItemQuantity(item.id, item.quantity - 1)
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={item.quantity <= 1 || loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
<span className="px-2 py-1 font-semibold min-w-[30px] text-center text-sm">
|
||||
{item.quantity}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateItemQuantity(item.id, item.quantity + 1)
|
||||
}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
${parseFloat(item.merchandise.price.amount).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
onClick={() => removeItem(item.id)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={loading}
|
||||
className="h-auto w-auto p-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Checkout Section */}
|
||||
{items.length > 0 && (
|
||||
<div className="border-t border-border p-6">
|
||||
{/* Subtotal */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-base font-semibold">Subtotal</span>
|
||||
<span className="text-lg font-bold">
|
||||
${totalAmount.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
Shipping and taxes calculated at checkout
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={loading || !checkoutUrl}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center space-x-2">
|
||||
<Loader size={16} />
|
||||
<span>Processing...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Checkout'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button onClick={closeCart} variant="link" className="w-full">
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
)}
|
||||
</SheetContent>
|
||||
</AnimatePresence>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Typography } from '@/components/Typography';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface CollectionImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
altText?: string | null;
|
||||
}
|
||||
|
||||
interface Collection {
|
||||
@@ -13,7 +12,7 @@ interface Collection {
|
||||
title: string;
|
||||
handle: string;
|
||||
description?: string;
|
||||
image?: CollectionImage;
|
||||
image?: CollectionImage | null;
|
||||
}
|
||||
|
||||
interface CollectionCardProps {
|
||||
@@ -22,47 +21,35 @@ interface CollectionCardProps {
|
||||
|
||||
const CollectionCard: React.FC<CollectionCardProps> = ({ collection }) => {
|
||||
return (
|
||||
<Link href={`/collections/${collection.handle}`} className="block group">
|
||||
<Card className="hover:shadow-xl transition-shadow duration-300 overflow-hidden py-0 gap-0">
|
||||
{/* Collection Image */}
|
||||
<div className="aspect-video overflow-hidden bg-muted">
|
||||
{collection.image ? (
|
||||
<img
|
||||
src={collection.image.url}
|
||||
alt={collection.image.altText || collection.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<i className="ri-folder-line text-6xl"></i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Info */}
|
||||
<CardContent className="p-6">
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
className="mb-3 font-semibold tracking-tight text-foreground transition-colors group-hover:text-muted-foreground"
|
||||
>
|
||||
{collection.title}
|
||||
</Typography>
|
||||
|
||||
{collection.description && (
|
||||
<p className="text-muted-foreground">
|
||||
{collection.description.substring(0, 100)}
|
||||
{collection.description.length > 100 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-foreground font-semibold group-hover:text-muted-foreground transition-colors flex items-center">
|
||||
<span>View Collection</span>
|
||||
<i className="ri-arrow-right-s-line ml-2"></i>
|
||||
<Link
|
||||
href={`/collections/${collection.handle}`}
|
||||
className="group block h-full"
|
||||
>
|
||||
{/* Collection Image */}
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
{collection.image ? (
|
||||
<Image
|
||||
src={collection.image.url}
|
||||
alt={collection.image.altText || collection.title}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 25vw, 50vw"
|
||||
className="object-cover 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-folder-line text-8xl"></i>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collection Info */}
|
||||
<div className="flex flex-col flex-1 py-2.5">
|
||||
<h3 className="text-sm font-medium text-foreground line-clamp-1">
|
||||
{collection.title}
|
||||
</h3>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionCard;
|
||||
export default CollectionCard;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import type { ShopifyCollection } from '@reacteditor/plugin-shopify';
|
||||
import { Boxes } from 'lucide-react';
|
||||
import CollectionDetail from '@/components/shopify/collection-detail';
|
||||
|
||||
export type CollectionDetailBlockProps = {
|
||||
collection?: ShopifyCollection | null;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Used two ways: dropped on `/collections/[handle]` it follows the route, so
|
||||
* `collection` stays empty and the picker only drives the editor preview;
|
||||
* dropped on any other page it pins to whichever collection is picked.
|
||||
*/
|
||||
const collectionDetailEditor: ComponentConfig<CollectionDetailBlockProps> = {
|
||||
label: 'Collection page',
|
||||
icon: <Boxes size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
title: '',
|
||||
},
|
||||
fields: {
|
||||
collection: {
|
||||
label: 'Collection',
|
||||
type: 'shopifyCollection',
|
||||
} as any,
|
||||
title: {
|
||||
label: 'Title override',
|
||||
type: 'text',
|
||||
placeholder: "Leave empty to use the collection's own title",
|
||||
contentEditable: true,
|
||||
},
|
||||
},
|
||||
render: ({ collection, title }) => (
|
||||
<CollectionDetail handle={collection?.handle} title={title} />
|
||||
),
|
||||
};
|
||||
|
||||
export default collectionDetailEditor;
|
||||
@@ -1,94 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import ProductCard from './product-card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import ProductFilters, { type ProductFilterFacet } from './product-filters';
|
||||
import ProductToolbar from './product-toolbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import {
|
||||
getCollectionProductsPage,
|
||||
type CollectionSortKey,
|
||||
} from '@/hooks/use-shopify-collections';
|
||||
import type { Product } from '@/hooks/use-shopify-products';
|
||||
|
||||
const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => {
|
||||
const handle = handleProp ?? '';
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
const { collection, loading, error, refetch } = useCollectionProducts(handle);
|
||||
const GRID_CLASSES =
|
||||
'grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12';
|
||||
|
||||
// Format title from handle
|
||||
interface SortOption {
|
||||
label: string;
|
||||
sortKey: CollectionSortKey;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ label: 'Featured', sortKey: 'COLLECTION_DEFAULT', reverse: false },
|
||||
{ label: 'Best Selling', sortKey: 'BEST_SELLING', reverse: false },
|
||||
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
|
||||
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
|
||||
{ label: 'Newest', sortKey: 'CREATED', reverse: true },
|
||||
];
|
||||
|
||||
interface CollectionDetailProps {
|
||||
/**
|
||||
* Pins the block to one collection. Left empty, it reads the `[handle]`
|
||||
* segment instead, which is what the `/collections/[handle]` template does.
|
||||
*/
|
||||
handle?: string;
|
||||
/** Overrides the collection's own title. Empty falls back to Shopify's. */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const CollectionDetail: React.FC<CollectionDetailProps> = ({
|
||||
handle: handleProp,
|
||||
title: titleProp,
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const handle = handleProp || (params?.handle as string);
|
||||
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [filters, setFilters] = useState<ProductFilterFacet[]>([]);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
|
||||
const [sortIndex, setSortIndex] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
|
||||
const sort = SORT_OPTIONS[sortIndex];
|
||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||
|
||||
// Fall back to the handle until the collection's real title arrives.
|
||||
const formattedTitle = handle
|
||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
? handle.replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
|
||||
: 'Collection';
|
||||
|
||||
if (loading || !handle) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mb-16 flex justify-center">
|
||||
<Skeleton className="h-12 w-64" />
|
||||
</div>
|
||||
useEffect(() => {
|
||||
// Reached when the editor was opened on the literal `[handle]` pattern
|
||||
// rather than a real collection URL. Drop the skeleton and say so rather
|
||||
// than spinning forever.
|
||||
if (!handle || handle === '[handle]') {
|
||||
setLoading(false);
|
||||
setError(
|
||||
'Open a collection page and add /editor to preview it, or pick a collection above.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
|
||||
<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="flex flex-col gap-3">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Could not load collection</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const page = await getCollectionProductsPage(handle, {
|
||||
first: PAGE_SIZE,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
const products = collection?.products || [];
|
||||
const title = collection?.title || formattedTitle;
|
||||
if (cancelled) return;
|
||||
|
||||
if (!page.collection) {
|
||||
setError('Collection not found');
|
||||
return;
|
||||
}
|
||||
|
||||
setTitle(page.collection.title);
|
||||
setProducts(page.products);
|
||||
setCursor(page.endCursor);
|
||||
setHasNextPage(page.hasNextPage);
|
||||
// Keep the facet list stable while a selection is active, so options
|
||||
// don't disappear out from under the panel.
|
||||
if (activeFilters.length === 0) setFilters(page.filters);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('Error fetching collection products:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load collection');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handle, sort.sortKey, sort.reverse, activeKey]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (loadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const page = await getCollectionProductsPage(handle, {
|
||||
first: PAGE_SIZE,
|
||||
after: cursor,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
setProducts((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
return [...prev, ...page.products.filter((p) => !seen.has(p.id))];
|
||||
});
|
||||
setCursor(page.endCursor);
|
||||
setHasNextPage(page.hasNextPage);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more products:', err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pt-4 pb-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
{title}
|
||||
</h2>
|
||||
<section className="bg-background py-10">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||
{titleProp || title || formattedTitle}
|
||||
</h1>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No Products in Collection
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
This collection doesn't have any products yet.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<ProductToolbar
|
||||
totalCount={products.length > 0 ? products.length : null}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
sortOptions={SORT_OPTIONS}
|
||||
sortIndex={sortIndex}
|
||||
onSortChange={setSortIndex}
|
||||
activeFilterCount={activeFilters.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{loading ? (
|
||||
<div className={GRID_CLASSES}>
|
||||
{Array.from({ length: 10 }).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 w-4/5 bg-zinc-200"></div>
|
||||
<div className="h-4 w-1/4 bg-zinc-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeFilters.length > 0
|
||||
? 'No products matched your filters.'
|
||||
: "This collection doesn't have any products yet."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={GRID_CLASSES}>
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="mt-16 flex justify-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{loadingMore && <Loader size={16} />}
|
||||
{loadingMore ? 'Loading' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductFilters
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
activeFilters={activeFilters}
|
||||
onActiveFiltersChange={setActiveFilters}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { CollectionGrid, type CollectionGridProps } from "@/components/shopify/collection-grid";
|
||||
|
||||
const collectionGridEditor: ComponentConfig<CollectionGridProps> = {
|
||||
label: "Collections",
|
||||
icon: <FolderOpen size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
tagline: "Shop by collection",
|
||||
heading: "Curated edits",
|
||||
subheading: "Bundles built around the way you actually live.",
|
||||
layout: "tiles",
|
||||
limit: 6,
|
||||
},
|
||||
fields: {
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
layout: {
|
||||
label: "Layout",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Tiles", value: "tiles" },
|
||||
{ label: "Editorial", value: "editorial" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 12 },
|
||||
},
|
||||
render: (props) => <CollectionGrid {...props} />,
|
||||
};
|
||||
|
||||
export default collectionGridEditor;
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { shopifyFetch } from "@/services/shopify/client";
|
||||
import { GET_COLLECTIONS_QUERY } from "@/graphql/collections";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Typography } from "@/components/Typography";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type CollectionGridProps = {
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
layout: "tiles" | "editorial";
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type CollectionRow = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
image?: { url: string; altText?: string };
|
||||
};
|
||||
|
||||
export function CollectionGrid({
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
layout,
|
||||
limit,
|
||||
}: CollectionGridProps) {
|
||||
const [collections, setCollections] = useState<CollectionRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
shopifyFetch<any>({
|
||||
query: GET_COLLECTIONS_QUERY,
|
||||
variables: { first: limit },
|
||||
})
|
||||
.then((res) => {
|
||||
const list = (res.data?.collections?.edges ?? []).map((e: any) => e.node);
|
||||
setCollections(list);
|
||||
})
|
||||
.catch(() => setCollections([]));
|
||||
}, [limit]);
|
||||
|
||||
const isEditorial = layout === "editorial";
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align="center"
|
||||
size="lg"
|
||||
className="mx-auto mb-12"
|
||||
maxWidth="max-w-2xl"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
isEditorial
|
||||
? "grid grid-cols-1 gap-8 md:grid-cols-2"
|
||||
: "grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4"
|
||||
}
|
||||
>
|
||||
{(collections.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }) as any)
|
||||
: collections
|
||||
).map((c: CollectionRow) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={c.handle ? `/collections/${c.handle}` : "#"}
|
||||
className="group block"
|
||||
>
|
||||
<div
|
||||
className={`relative overflow-hidden rounded-md bg-muted ${isEditorial ? "aspect-[3/4] md:aspect-[5/6]" : "aspect-[4/5]"}`}
|
||||
>
|
||||
{c.image?.url ? (
|
||||
<img
|
||||
src={c.image.url}
|
||||
alt={c.image.altText || c.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
{isEditorial ? (
|
||||
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/60 via-transparent to-transparent p-8">
|
||||
<div>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
className="font-semibold tracking-tight text-white"
|
||||
>
|
||||
{c.title}
|
||||
</Typography>
|
||||
<span className="mt-2 inline-flex text-xs uppercase tracking-[0.2em] text-white/80">
|
||||
Shop now
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isEditorial ? (
|
||||
<div className="mt-4">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-medium tracking-tight text-foreground"
|
||||
>
|
||||
{c.title}
|
||||
</Typography>
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { CollectionView, type CollectionProps } from "@/components/shopify/collection";
|
||||
|
||||
const collectionEditor: ComponentConfig<CollectionProps> = {
|
||||
label: "Collection page",
|
||||
icon: <FolderOpen size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
showDescription: "yes",
|
||||
showCoverImage: "yes",
|
||||
customCoverImage: "",
|
||||
columns: "4",
|
||||
limit: 24,
|
||||
defaultSort: "BEST_SELLING",
|
||||
showAvailability: "yes",
|
||||
showPriceRange: "yes",
|
||||
showProductType: "no",
|
||||
productTypeOptions: [],
|
||||
showVendor: "no",
|
||||
vendorOptions: [],
|
||||
showTags: "no",
|
||||
tagOptions: [],
|
||||
showColor: "yes",
|
||||
colorOptions: [
|
||||
{ label: "Black", color: "#000000" },
|
||||
{ label: "White", color: "#FFFFFF" },
|
||||
{ label: "Navy", color: "#1e3a5f" },
|
||||
],
|
||||
showStyle: "no",
|
||||
styleOptions: [],
|
||||
showSize: "yes",
|
||||
sizeOptions: [
|
||||
{ label: "XS" },
|
||||
{ label: "S" },
|
||||
{ label: "M" },
|
||||
{ label: "L" },
|
||||
{ label: "XL" },
|
||||
],
|
||||
showMaterial: "no",
|
||||
materialOptions: [],
|
||||
metafieldFilters: [],
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", type: "shopifyCollection" } as any,
|
||||
showDescription: {
|
||||
label: "Description",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
showCoverImage: {
|
||||
label: "Cover image",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
customCoverImage: {
|
||||
label: "Custom cover image",
|
||||
type: "image",
|
||||
},
|
||||
columns: {
|
||||
label: "Columns",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "2", value: "2" },
|
||||
{ label: "3", value: "3" },
|
||||
{ label: "4", value: "4" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Products per page", type: "number", min: 4, max: 48 },
|
||||
defaultSort: {
|
||||
label: "Default sort",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Best Selling", value: "BEST_SELLING" },
|
||||
{ label: "Newest", value: "CREATED" },
|
||||
{ label: "Price: Low to High", value: "PRICE" },
|
||||
{ label: "Alphabetical", value: "TITLE" },
|
||||
],
|
||||
},
|
||||
showAvailability: {
|
||||
label: "Availability filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
showPriceRange: {
|
||||
label: "Price range filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
showColor: {
|
||||
label: "Color filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
colorOptions: {
|
||||
label: "Colors",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "", color: "#000000" },
|
||||
getItemSummary: (it: any) => it?.label || "Color",
|
||||
arrayFields: {
|
||||
label: { label: "Color name", type: "text" },
|
||||
color: { label: "Color", type: "color" },
|
||||
},
|
||||
},
|
||||
showStyle: {
|
||||
label: "Style filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
styleOptions: {
|
||||
label: "Styles",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Style",
|
||||
arrayFields: {
|
||||
label: { label: "Style name", type: "text" },
|
||||
},
|
||||
},
|
||||
showSize: {
|
||||
label: "Size filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
sizeOptions: {
|
||||
label: "Sizes",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Size",
|
||||
arrayFields: {
|
||||
label: { label: "Size name", type: "text" },
|
||||
},
|
||||
},
|
||||
showMaterial: {
|
||||
label: "Material filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
materialOptions: {
|
||||
label: "Materials",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Material",
|
||||
arrayFields: {
|
||||
label: { label: "Material name", type: "text" },
|
||||
},
|
||||
},
|
||||
showVendor: {
|
||||
label: "Brand / vendor filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
vendorOptions: {
|
||||
label: "Brands",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Brand",
|
||||
arrayFields: {
|
||||
label: { label: "Brand name", type: "text" },
|
||||
},
|
||||
},
|
||||
showProductType: {
|
||||
label: "Product type filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
productTypeOptions: {
|
||||
label: "Product types",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Type",
|
||||
arrayFields: {
|
||||
label: { label: "Type name", type: "text" },
|
||||
},
|
||||
},
|
||||
showTags: {
|
||||
label: "Tags filter",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Show", value: "yes" },
|
||||
{ label: "Hide", value: "no" },
|
||||
],
|
||||
},
|
||||
tagOptions: {
|
||||
label: "Tags",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (it: any) => it?.label || "Tag",
|
||||
arrayFields: {
|
||||
label: { label: "Tag name", type: "text" },
|
||||
},
|
||||
},
|
||||
metafieldFilters: {
|
||||
label: "Metafield filters",
|
||||
type: "array",
|
||||
defaultItemProps: { namespace: "", key: "", label: "", values: [{ label: "" }] },
|
||||
getItemSummary: (it: any) => it?.label || it?.key || "Metafield",
|
||||
arrayFields: {
|
||||
namespace: { label: "Namespace", type: "text" },
|
||||
key: { label: "Key", type: "text" },
|
||||
label: { label: "Label", type: "text" },
|
||||
values: {
|
||||
label: "Values",
|
||||
type: "array",
|
||||
defaultItemProps: { label: "" },
|
||||
getItemSummary: (v: any) => v?.label || "Value",
|
||||
arrayFields: {
|
||||
label: { label: "Value", type: "text" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <CollectionView {...props} />,
|
||||
};
|
||||
|
||||
export default collectionEditor;
|
||||
@@ -1,552 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
||||
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import type { ShopifyCollection } from '@reacteditor/field-shopify';
|
||||
import {
|
||||
useCollectionProducts,
|
||||
type CollectionSortKey,
|
||||
type ProductFilter,
|
||||
} from '@/hooks/use-shopify-collections';
|
||||
import { ProductCard } from './product-card';
|
||||
import { Typography } from '@/components/Typography';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FilterOption = { label: string };
|
||||
type ColorOption = { label: string; color: string };
|
||||
|
||||
export type CollectionProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
showDescription: 'yes' | 'no';
|
||||
showCoverImage: 'yes' | 'no';
|
||||
customCoverImage: string;
|
||||
columns: '2' | '3' | '4';
|
||||
limit: number;
|
||||
defaultSort: CollectionSortKey;
|
||||
showAvailability: 'yes' | 'no';
|
||||
showPriceRange: 'yes' | 'no';
|
||||
showProductType: 'yes' | 'no';
|
||||
productTypeOptions: FilterOption[];
|
||||
showVendor: 'yes' | 'no';
|
||||
vendorOptions: FilterOption[];
|
||||
showTags: 'yes' | 'no';
|
||||
tagOptions: FilterOption[];
|
||||
showColor: 'yes' | 'no';
|
||||
colorOptions: ColorOption[];
|
||||
showStyle: 'yes' | 'no';
|
||||
styleOptions: FilterOption[];
|
||||
showSize: 'yes' | 'no';
|
||||
sizeOptions: FilterOption[];
|
||||
showMaterial: 'yes' | 'no';
|
||||
materialOptions: FilterOption[];
|
||||
metafieldFilters: { namespace: string; key: string; label: string; values: { label: string }[] }[];
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { label: string; value: CollectionSortKey }[] = [
|
||||
{ label: 'Best Selling', value: 'BEST_SELLING' },
|
||||
{ label: 'Newest', value: 'CREATED' },
|
||||
{ label: 'Price: Low to High', value: 'PRICE' },
|
||||
{ label: 'Alphabetical', value: 'TITLE' },
|
||||
];
|
||||
|
||||
const colClass: Record<CollectionProps['columns'], string> = {
|
||||
'2': 'grid-cols-2',
|
||||
'3': 'grid-cols-2 md:grid-cols-3',
|
||||
'4': 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// ─── Filter group (collapsible) ────────────────────────────────────────────────
|
||||
|
||||
function FilterGroup({ label, children, defaultOpen = true }: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-b border-border py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.15em] text-foreground"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn('transition-transform', open ? 'rotate-180' : '')}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="mt-3 space-y-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, label }: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
||||
checked ? 'border-foreground bg-foreground' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<svg viewBox="0 0 10 8" className="h-2.5 w-2.5 fill-background" aria-hidden>
|
||||
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ActiveFilters = {
|
||||
availability: boolean;
|
||||
productTypes: string[];
|
||||
vendors: string[];
|
||||
tags: string[];
|
||||
colors: string[];
|
||||
styles: string[];
|
||||
sizes: string[];
|
||||
materials: string[];
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
metafieldValues: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function Sidebar({
|
||||
props,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
props: CollectionProps;
|
||||
active: ActiveFilters;
|
||||
onChange: (patch: Partial<ActiveFilters>) => void;
|
||||
}) {
|
||||
const productTypes = (props.productTypeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const vendors = (props.vendorOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const tags = (props.tagOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const colors = (props.colorOptions ?? []) as ColorOption[];
|
||||
const styles = (props.styleOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const sizes = (props.sizeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const materials = (props.materialOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const metafieldFilters = (props.metafieldFilters ?? []).filter((mf) => mf.namespace && mf.key);
|
||||
|
||||
function toggle(key: 'productTypes' | 'vendors' | 'tags' | 'colors' | 'styles' | 'sizes' | 'materials', value: string) {
|
||||
const arr = active[key];
|
||||
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.showAvailability === 'yes' && (
|
||||
<FilterGroup label="Availability">
|
||||
<Checkbox
|
||||
checked={active.availability}
|
||||
onChange={(v) => onChange({ availability: v })}
|
||||
label="In stock"
|
||||
/>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showPriceRange === 'yes' && (
|
||||
<FilterGroup label="Price">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
value={active.minPrice}
|
||||
onChange={(e) => onChange({ minPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
value={active.maxPrice}
|
||||
onChange={(e) => onChange({ maxPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showVendor === 'yes' && vendors.length > 0 && (
|
||||
<FilterGroup label="Brand">
|
||||
{vendors.map((v) => (
|
||||
<Checkbox
|
||||
key={v}
|
||||
checked={active.vendors.includes(v)}
|
||||
onChange={() => toggle('vendors', v)}
|
||||
label={v}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showColor === 'yes' && colors.length > 0 && (
|
||||
<FilterGroup label="Color">
|
||||
{colors.filter((c) => c.label).map((c) => (
|
||||
<label
|
||||
key={c.label}
|
||||
className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active.colors.includes(c.label)}
|
||||
onChange={() => toggle('colors', c.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 rounded-full border-2',
|
||||
active.colors.includes(c.label) ? 'border-foreground' : 'border-transparent',
|
||||
)}
|
||||
style={{ backgroundColor: c.color || undefined }}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showStyle === 'yes' && styles.length > 0 && (
|
||||
<FilterGroup label="Style">
|
||||
{styles.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.styles.includes(s)}
|
||||
onChange={() => toggle('styles', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showSize === 'yes' && sizes.length > 0 && (
|
||||
<FilterGroup label="Size">
|
||||
{sizes.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.sizes.includes(s)}
|
||||
onChange={() => toggle('sizes', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showMaterial === 'yes' && materials.length > 0 && (
|
||||
<FilterGroup label="Material">
|
||||
{materials.map((m) => (
|
||||
<Checkbox
|
||||
key={m}
|
||||
checked={active.materials.includes(m)}
|
||||
onChange={() => toggle('materials', m)}
|
||||
label={m}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showProductType === 'yes' && productTypes.length > 0 && (
|
||||
<FilterGroup label="Product type">
|
||||
{productTypes.map((pt) => (
|
||||
<Checkbox
|
||||
key={pt}
|
||||
checked={active.productTypes.includes(pt)}
|
||||
onChange={() => toggle('productTypes', pt)}
|
||||
label={pt}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showTags === 'yes' && tags.length > 0 && (
|
||||
<FilterGroup label="Tags">
|
||||
{tags.map((t) => (
|
||||
<Checkbox
|
||||
key={t}
|
||||
checked={active.tags.includes(t)}
|
||||
onChange={() => toggle('tags', t)}
|
||||
label={t}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{metafieldFilters.map((mf, i) => {
|
||||
const mfKey = `${mf.namespace}.${mf.key}`;
|
||||
const selected = active.metafieldValues[mfKey] ?? [];
|
||||
return (
|
||||
<FilterGroup key={mfKey + i} label={mf.label || mfKey}>
|
||||
{mf.values.map((v) => v.label).filter(Boolean).map((val) => (
|
||||
<Checkbox
|
||||
key={val}
|
||||
checked={selected.includes(val)}
|
||||
onChange={(checked) => {
|
||||
const next = checked
|
||||
? [...selected, val]
|
||||
: selected.filter((v) => v !== val);
|
||||
onChange({ metafieldValues: { ...active.metafieldValues, [mfKey]: next } });
|
||||
}}
|
||||
label={val}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Build Shopify ProductFilter array ──────────────────────────────────────────
|
||||
|
||||
function buildProductFilters(active: ActiveFilters): ProductFilter[] {
|
||||
const filters: ProductFilter[] = [];
|
||||
|
||||
if (active.availability) filters.push({ available: true });
|
||||
|
||||
if (active.minPrice !== '' || active.maxPrice !== '') {
|
||||
filters.push({
|
||||
price: {
|
||||
min: active.minPrice !== '' ? parseFloat(active.minPrice) : undefined,
|
||||
max: active.maxPrice !== '' ? parseFloat(active.maxPrice) : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const pt of active.productTypes) filters.push({ productType: pt });
|
||||
for (const v of active.vendors) filters.push({ productVendor: v });
|
||||
for (const t of active.tags) filters.push({ tag: t });
|
||||
|
||||
for (const c of active.colors) filters.push({ variantOption: { name: 'Color', value: c } });
|
||||
for (const s of active.styles) filters.push({ variantOption: { name: 'Style', value: s } });
|
||||
for (const s of active.sizes) filters.push({ variantOption: { name: 'Size', value: s } });
|
||||
for (const m of active.materials) filters.push({ variantOption: { name: 'Material', value: m } });
|
||||
|
||||
for (const [mfKey, vals] of Object.entries(active.metafieldValues)) {
|
||||
const [namespace, key] = mfKey.split('.');
|
||||
for (const value of vals) {
|
||||
filters.push({ productMetafield: { namespace, key, value } });
|
||||
}
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function CollectionView(props: CollectionProps) {
|
||||
const { collection: selected, showDescription, showCoverImage, customCoverImage, columns, limit, defaultSort } = props;
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = selected?.handle ?? routeHandle ?? '';
|
||||
|
||||
const [sort, setSort] = useState<CollectionSortKey>(defaultSort);
|
||||
const [reverse, setReverse] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [active, setActive] = useState<ActiveFilters>({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
|
||||
const patchActive = useCallback((patch: Partial<ActiveFilters>) => {
|
||||
setActive((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setActive({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const productFilters = buildProductFilters(active);
|
||||
|
||||
const handleSortChange = (value: string) => {
|
||||
if (value === 'PRICE_DESC') {
|
||||
setSort('PRICE');
|
||||
setReverse(true);
|
||||
} else {
|
||||
setSort(value as CollectionSortKey);
|
||||
setReverse(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sortValue = sort === 'PRICE' && reverse ? 'PRICE_DESC' : sort;
|
||||
|
||||
const { collection, loading, hasNextPage, fetchMore } = useCollectionProducts(handle, {
|
||||
first: limit,
|
||||
sortKey: sort,
|
||||
reverse,
|
||||
filters: productFilters.length ? productFilters : undefined,
|
||||
});
|
||||
|
||||
const products = collection?.products ?? [];
|
||||
const description = collection?.description ?? (selected as any)?.description;
|
||||
const collectionImage = customCoverImage || collection?.image?.url;
|
||||
|
||||
if (!selected && !routeHandle) {
|
||||
return (
|
||||
<section className="bg-background pb-24 pt-12 md:pt-20">
|
||||
<Container>
|
||||
<header className="mx-auto mb-14 flex max-w-2xl flex-col items-center gap-3 text-center">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
</header>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-background pb-24 pt-12 md:pt-20">
|
||||
<Container>
|
||||
{/* Cover image */}
|
||||
{showCoverImage === 'yes' && collectionImage && (
|
||||
<div className="mb-10 overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={collectionImage}
|
||||
alt={collection?.title ?? ''}
|
||||
className="h-48 w-full object-cover md:h-72 lg:h-80"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-10">
|
||||
<p className="mb-3 text-xs uppercase tracking-[0.2em] text-muted-foreground">
|
||||
Collection
|
||||
</p>
|
||||
<Typography variant="h1">
|
||||
{collection?.title ?? (selected as any)?.title ?? routeHandle}
|
||||
</Typography>
|
||||
{showDescription === 'yes' && description ? (
|
||||
<Typography variant="subtitle1" className="mt-4 max-w-2xl">
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
{/* Filter + sort bar */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
Filters
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-4">
|
||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
||||
</div>
|
||||
<SheetFooter className="flex-row gap-2 border-t border-border">
|
||||
<Button variant="outline" className="flex-1" onClick={clearAll}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
|
||||
Search
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="hidden text-sm text-muted-foreground sm:block">
|
||||
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
||||
</p>
|
||||
<Select value={sortValue} onValueChange={handleSortChange}>
|
||||
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
||||
<SelectValue>
|
||||
{[...SORT_OPTIONS, { label: 'Price: High to Low', value: 'PRICE_DESC' }].find((o) => o.value === sortValue)?.label}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
<SelectItem value="PRICE_DESC">Price: High to Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className={cn('grid gap-x-6 gap-y-10', colClass[columns])}>
|
||||
{loading
|
||||
? Array.from({ length: limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: products.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
|
||||
{!loading && products.length === 0 && (
|
||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||
No products found in this collection.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && !loading && (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchMore}
|
||||
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import Collections from '@/components/shopify/collections';
|
||||
|
||||
export type CollectionsBlockProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
const collectionsEditor: ComponentConfig<CollectionsBlockProps> = {
|
||||
label: 'Collection grid',
|
||||
icon: <FolderOpen size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
title: 'Our Collections',
|
||||
subtitle: 'Discover our carefully crafted worlds',
|
||||
},
|
||||
fields: {
|
||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||
subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true },
|
||||
},
|
||||
render: (props) => <Collections {...props} />,
|
||||
};
|
||||
|
||||
export default collectionsEditor;
|
||||
@@ -3,27 +3,49 @@
|
||||
import React from 'react';
|
||||
import { useCollections } from '@/hooks/use-shopify-collections';
|
||||
import CollectionCard from './collection-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const Collections: React.FC = () => {
|
||||
const { collections, loading, error, refetch } = useCollections(20);
|
||||
interface CollectionsProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const SectionHeader: React.FC<{ title: string; subtitle?: string }> = ({
|
||||
title,
|
||||
subtitle,
|
||||
}) => (
|
||||
<>
|
||||
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-center text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const GRID_CLASSES = 'grid grid-cols-2 lg:grid-cols-4 gap-x-8 gap-y-12';
|
||||
|
||||
const Collections: React.FC<CollectionsProps> = ({
|
||||
title = 'Our Collections',
|
||||
subtitle = 'Discover our carefully crafted worlds',
|
||||
}) => {
|
||||
const { collections, loading, error, refetch } = useCollections(12);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
<div className="py-20 bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<SectionHeader title={title} subtitle={subtitle} />
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-video bg-muted"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-8 bg-muted rounded mb-4"></div>
|
||||
<div className="h-4 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className={GRID_CLASSES}>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="aspect-square bg-zinc-100"></div>
|
||||
<div className="pt-4">
|
||||
<div className="h-4 bg-zinc-200 w-3/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -35,20 +57,13 @@ const Collections: React.FC = () => {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Could not load collections</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div className="py-20 bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 text-center">
|
||||
<SectionHeader title={title} subtitle={subtitle} />
|
||||
<p className="text-sm text-muted-foreground mb-6">{error}</p>
|
||||
<Button onClick={refetch} variant="outline">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,35 +71,23 @@ const Collections: React.FC = () => {
|
||||
|
||||
if (collections.length === 0) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-5xl font-bold mb-8 font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
|
||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-folder-line text-4xl text-muted-foreground mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No Collections Found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Check back later or configure your Shopify store connection.
|
||||
</p>
|
||||
</div>
|
||||
<div className="py-20 bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 text-center">
|
||||
<SectionHeader title={title} subtitle={subtitle} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Collections will appear here once added to your Shopify store.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
Our Collections
|
||||
</h2>
|
||||
<div className="py-20 bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<SectionHeader title={title} subtitle={subtitle} />
|
||||
|
||||
{/* Collections Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<div className={GRID_CLASSES}>
|
||||
{collections.map((collection) => (
|
||||
<CollectionCard key={collection.id} collection={collection} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { Text } from 'lucide-react';
|
||||
import ContentSection, {
|
||||
type ContentSectionProps,
|
||||
} from '@/components/shopify/content-section';
|
||||
|
||||
const contentSectionEditor: ComponentConfig<ContentSectionProps> = {
|
||||
label: 'Content section',
|
||||
icon: <Text size={16} />,
|
||||
category: 'content',
|
||||
defaultProps: {
|
||||
eyebrow: '',
|
||||
heading: 'About',
|
||||
body: 'Tell your store’s story here.\n\nBlank lines start a new paragraph.',
|
||||
imageUrl: '',
|
||||
imageAlt: '',
|
||||
},
|
||||
fields: {
|
||||
eyebrow: { label: 'Eyebrow', type: 'text', contentEditable: true },
|
||||
heading: { label: 'Heading', type: 'text', contentEditable: true },
|
||||
body: { label: 'Body', type: 'textarea', contentEditable: true },
|
||||
imageUrl: { label: 'Image', type: 'image' },
|
||||
imageAlt: { label: 'Image alt text', type: 'text' },
|
||||
},
|
||||
render: (props) => <ContentSection {...props} />,
|
||||
};
|
||||
|
||||
export default contentSectionEditor;
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export interface ContentSectionProps {
|
||||
eyebrow?: React.ReactNode;
|
||||
heading?: React.ReactNode;
|
||||
/**
|
||||
* Plain text when it comes from a `page.json`; while the field is being
|
||||
* edited inline the editor hands over a ReactNode instead, so this must not
|
||||
* assume a string.
|
||||
*/
|
||||
body?: React.ReactNode;
|
||||
imageUrl?: string;
|
||||
imageAlt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic prose section for the non-commerce routes (about, landing copy).
|
||||
* Typography matches the storefront's other sections — the editor supplies the
|
||||
* words and the image, never the layout.
|
||||
*/
|
||||
const ContentSection: React.FC<ContentSectionProps> = ({
|
||||
eyebrow,
|
||||
heading,
|
||||
body,
|
||||
imageUrl,
|
||||
imageAlt,
|
||||
}) => {
|
||||
return (
|
||||
<section className="bg-background py-20">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{eyebrow && (
|
||||
<p className="text-[11px] font-mono uppercase tracking-widest text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{heading && (
|
||||
<h1 className="mt-3 text-3xl md:text-4xl font-normal text-foreground">
|
||||
{heading}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
{imageUrl && (
|
||||
<div className="relative mt-10 aspect-[3/2] overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
// `heading` is a node while it's edited inline, and alt text
|
||||
// has to be a string — fall back to empty rather than stringify.
|
||||
alt={imageAlt || (typeof heading === 'string' ? heading : '')}
|
||||
fill
|
||||
sizes="(min-width: 768px) 42rem, 100vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{body && (
|
||||
<div className="mt-8 flex flex-col gap-4 text-[15px] leading-7 text-foreground">
|
||||
{typeof body === 'string'
|
||||
? // Authored as plain paragraphs; blank lines separate them.
|
||||
body
|
||||
.split(/\n{2,}/)
|
||||
.filter((paragraph) => paragraph.trim())
|
||||
.map((paragraph, index) => <p key={index}>{paragraph}</p>)
|
||||
: // Mid-edit: render the editor's node as-is so inline editing
|
||||
// keeps working. Paragraph splitting resumes once saved.
|
||||
body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentSection;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Star } from "lucide-react";
|
||||
import { FeaturedProductView, type FeaturedProductProps } from "@/components/shopify/featured-product";
|
||||
|
||||
const featuredProductEditor: ComponentConfig<FeaturedProductProps> = {
|
||||
label: "Featured product",
|
||||
icon: <Star size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
product: null,
|
||||
tagline: "Featured",
|
||||
ctaLabel: "Add to bag",
|
||||
align: "left",
|
||||
tone: "default",
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Product", type: "shopifyProduct" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
align: {
|
||||
label: "Image alignment",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Image left", value: "left" },
|
||||
{ label: "Image right", value: "right" },
|
||||
],
|
||||
},
|
||||
tone: {
|
||||
label: "Tone",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "Default", value: "default" },
|
||||
{ label: "Muted", value: "muted" },
|
||||
],
|
||||
},
|
||||
},
|
||||
render: (props) => <FeaturedProductView {...props} />,
|
||||
};
|
||||
|
||||
export default featuredProductEditor;
|
||||
@@ -1,128 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
||||
import { useProduct } from "@/hooks/use-shopify-products";
|
||||
import { useShopifyCart } from "@/hooks/use-shopify-cart";
|
||||
import { Typography } from "@/components/Typography";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type FeaturedProductProps = {
|
||||
product: ShopifyProduct | null;
|
||||
tagline: string;
|
||||
ctaLabel: string;
|
||||
align: "left" | "right";
|
||||
tone: "default" | "muted";
|
||||
};
|
||||
|
||||
export function FeaturedProductView({
|
||||
product: selected,
|
||||
tagline,
|
||||
ctaLabel,
|
||||
align,
|
||||
tone,
|
||||
}: FeaturedProductProps) {
|
||||
const { product: full, loading } = useProduct(selected?.handle ?? null);
|
||||
const product: any = full ?? selected;
|
||||
const cart = useShopifyCart();
|
||||
|
||||
if (!product) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"py-20 md:py-28",
|
||||
tone === "muted" ? "bg-muted/40" : "bg-background",
|
||||
)}
|
||||
>
|
||||
<Container className="grid grid-cols-1 items-center gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className={cn(align === "right" && "md:order-2")}>
|
||||
<Skeleton className="aspect-[4/5] w-full" />
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start gap-5">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<div className="w-full max-w-md space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
<Skeleton className="h-11 w-32 rounded-md" />
|
||||
<Skeleton className="h-11 w-32 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const image =
|
||||
product.images?.edges?.[0]?.node ?? (selected as any)?.featuredImage ?? null;
|
||||
const variant = product.variants?.edges?.[0]?.node;
|
||||
const price = product.priceRange?.minVariantPrice;
|
||||
const formatted = price
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount))
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={
|
||||
tone === "muted"
|
||||
? "bg-muted/40 py-20 md:py-28"
|
||||
: "bg-background py-20 md:py-28"
|
||||
}
|
||||
>
|
||||
<Container className="grid grid-cols-1 items-center gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className={align === "right" ? "md:order-2" : ""}>
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || product.title}
|
||||
className="aspect-[4/5] w-full rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-[4/5] w-full rounded-md bg-muted" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-5">
|
||||
{tagline ? (
|
||||
<Typography variant="caption">{tagline}</Typography>
|
||||
) : null}
|
||||
<Typography variant="h2">{product.title}</Typography>
|
||||
{formatted ? (
|
||||
<Typography variant="subtitle1" className="text-foreground font-medium">
|
||||
{formatted}
|
||||
</Typography>
|
||||
) : null}
|
||||
{product.description ? (
|
||||
<Typography variant="body2" className="max-w-md text-muted-foreground">
|
||||
{product.description}
|
||||
</Typography>
|
||||
) : null}
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!variant) return;
|
||||
await cart.addItem(variant.id, 1);
|
||||
cart.openCart();
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-md bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background hover:opacity-90"
|
||||
>
|
||||
{ctaLabel}
|
||||
</button>
|
||||
<Link
|
||||
href={`/products/${product.handle}`}
|
||||
className="inline-flex items-center justify-center rounded-md border border-foreground px-6 py-3 text-sm font-medium tracking-wide hover:opacity-80"
|
||||
>
|
||||
View details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { PanelBottom } from 'lucide-react';
|
||||
import Footer from '@/components/shopify/footer';
|
||||
|
||||
export type FooterBlockProps = {
|
||||
storeName?: string;
|
||||
logoUrl?: string;
|
||||
copyright?: string;
|
||||
links?: Array<{ label: string; url: string }>;
|
||||
instagramUrl?: string;
|
||||
tiktokUrl?: string;
|
||||
facebookUrl?: string;
|
||||
};
|
||||
|
||||
const footerEditor: ComponentConfig<FooterBlockProps> = {
|
||||
label: 'Footer',
|
||||
icon: <PanelBottom size={16} />,
|
||||
category: 'navigation',
|
||||
global: true,
|
||||
defaultProps: {
|
||||
storeName: 'Shop',
|
||||
logoUrl: '',
|
||||
copyright: '© 2026 Shop. All rights reserved.',
|
||||
links: [
|
||||
{ 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: '#',
|
||||
},
|
||||
fields: {
|
||||
storeName: { label: 'Store name', type: 'text', contentEditable: true },
|
||||
logoUrl: { label: 'Logo', type: 'image' },
|
||||
copyright: { label: 'Copyright', type: 'text', contentEditable: true },
|
||||
links: {
|
||||
label: 'Links',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: 'Link', url: '/' },
|
||||
getItemSummary: (item) => item?.label || 'Link',
|
||||
arrayFields: {
|
||||
label: { label: 'Label', type: 'text', contentEditable: true },
|
||||
url: { label: 'Link', type: 'text' },
|
||||
},
|
||||
},
|
||||
instagramUrl: { label: 'Instagram URL', type: 'text' },
|
||||
tiktokUrl: { label: 'TikTok URL', type: 'text' },
|
||||
facebookUrl: { label: 'Facebook URL', type: 'text' },
|
||||
},
|
||||
render: (props) => <Footer {...props} />,
|
||||
};
|
||||
|
||||
export default footerEditor;
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
RiInstagramLine,
|
||||
RiTiktokLine,
|
||||
RiFacebookFill,
|
||||
} from '@remixicon/react';
|
||||
import Logo from '@/components/logo';
|
||||
|
||||
export interface FooterLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
storeName?: string;
|
||||
logoUrl?: string;
|
||||
copyright?: string;
|
||||
links?: FooterLink[];
|
||||
instagramUrl?: string;
|
||||
tiktokUrl?: string;
|
||||
facebookUrl?: string;
|
||||
}
|
||||
|
||||
const Footer: React.FC<FooterProps> = ({
|
||||
storeName = 'Shop',
|
||||
logoUrl,
|
||||
copyright,
|
||||
links = [
|
||||
{ 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-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 py-10">
|
||||
{/* Left-aligned: the bottom-right corner belongs to the floating
|
||||
assistant launcher. Links and socials sit on separate rows so the
|
||||
icons aren't crammed onto the end of the link list. */}
|
||||
<div className="flex flex-col items-center gap-y-5 sm:items-start">
|
||||
<Logo
|
||||
src={logoUrl}
|
||||
storeName={storeName}
|
||||
imageClassName="h-5"
|
||||
textClassName="text-base"
|
||||
/>
|
||||
|
||||
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 sm:justify-start">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.url}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col-reverse items-center gap-3 sm:flex-row sm:gap-x-5">
|
||||
<span className="flex items-center gap-x-4">
|
||||
{socials.map(({ label, url, Icon }) => (
|
||||
<a
|
||||
key={label}
|
||||
href={url}
|
||||
aria-label={label}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon size={18} />
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
|
||||
<p className="text-sm text-muted-foreground leading-5">
|
||||
{copyright || `© ${storeName}. All rights reserved.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { Menu } from 'lucide-react';
|
||||
import Header from '@/components/shopify/header';
|
||||
|
||||
export type HeaderBlockProps = {
|
||||
storeName?: string;
|
||||
logoUrl?: string;
|
||||
showAnnouncement?: 'yes' | 'no';
|
||||
announcement?: string;
|
||||
announcementUrl?: string;
|
||||
links?: Array<{ label: string; url: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Content only: the store name, the logo, the announcement copy and the nav
|
||||
* labels. Stickiness, backdrop blur, spacing and the cart/search/account
|
||||
* affordances are the component's business, not the editor's.
|
||||
*/
|
||||
const headerEditor: ComponentConfig<HeaderBlockProps> = {
|
||||
label: 'Header',
|
||||
icon: <Menu size={16} />,
|
||||
category: 'navigation',
|
||||
// Shared across pages: edit it once and every page.json picks it up.
|
||||
global: true,
|
||||
defaultProps: {
|
||||
storeName: 'Shop',
|
||||
logoUrl: '',
|
||||
showAnnouncement: 'yes',
|
||||
announcement: 'Free shipping on orders over $100',
|
||||
announcementUrl: '',
|
||||
links: [
|
||||
{ label: 'Products', url: '/' },
|
||||
{ label: 'Collections', url: '/collections' },
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
storeName: { label: 'Store name', type: 'text', contentEditable: true },
|
||||
logoUrl: { label: 'Logo', type: 'image' },
|
||||
showAnnouncement: {
|
||||
label: 'Announcement bar',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
announcement: {
|
||||
label: 'Announcement',
|
||||
type: 'text',
|
||||
// Deliberately not `contentEditable`: an inline-edited field arrives as a
|
||||
// ReactNode, which stays truthy once emptied, so the bar would linger as
|
||||
// an empty band. Keeping it a plain string makes "blank hides it" work.
|
||||
placeholder: 'Leave empty to hide the bar',
|
||||
},
|
||||
announcementUrl: { label: 'Announcement link', type: 'text' },
|
||||
links: {
|
||||
label: 'Navigation links',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: 'Link', url: '/' },
|
||||
getItemSummary: (item) => item?.label || 'Link',
|
||||
arrayFields: {
|
||||
label: { label: 'Label', type: 'text', contentEditable: true },
|
||||
url: { label: 'Link', type: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
render: ({
|
||||
storeName,
|
||||
logoUrl,
|
||||
showAnnouncement,
|
||||
announcement,
|
||||
announcementUrl,
|
||||
links,
|
||||
}) => (
|
||||
<Header
|
||||
storeName={storeName}
|
||||
logoUrl={logoUrl}
|
||||
links={links}
|
||||
// Header decides visibility: blank copy hides the bar, and the toggle
|
||||
// hides it regardless — needed because an inline-edited field is a node,
|
||||
// which can't be inspected for emptiness.
|
||||
showAnnouncement={showAnnouncement}
|
||||
announcement={announcement}
|
||||
announcementUrl={announcementUrl}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default headerEditor;
|
||||
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
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 SearchDialog from '@/components/shopify/search-dialog';
|
||||
import AccountMenu from '@/components/shopify/account-menu';
|
||||
import ShopMenu from '@/components/shopify/shop-menu';
|
||||
import { RiShoppingBagLine, RiCloseLine, RiMenu3Line } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Logo from '@/components/logo';
|
||||
|
||||
const CartIcon: React.FC = () => {
|
||||
const toggleCart = useCartStore((s) => s.toggleCart);
|
||||
const cart = useCartStore((s) => s.cart);
|
||||
const itemCount =
|
||||
cart?.lines?.edges?.reduce((sum, { node }) => sum + node.quantity, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={toggleCart}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Open bag (${itemCount})`}
|
||||
className="relative rounded-full"
|
||||
>
|
||||
<RiShoppingBagLine className="size-5" />
|
||||
{itemCount > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export interface NavLink {
|
||||
label: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface HeaderProps {
|
||||
storeName?: string;
|
||||
logoUrl?: string;
|
||||
links?: NavLink[];
|
||||
/** Thin bar above the nav. Blank copy hides it. */
|
||||
announcement?: React.ReactNode;
|
||||
announcementUrl?: string;
|
||||
/** Hides the bar outright, whatever the copy says. */
|
||||
showAnnouncement?: 'yes' | 'no';
|
||||
}
|
||||
|
||||
const Header: React.FC<HeaderProps> = ({
|
||||
storeName = 'Logo',
|
||||
logoUrl,
|
||||
links = [
|
||||
{ label: 'Shop', url: '/' },
|
||||
{ label: 'Collections', url: '/collections' },
|
||||
],
|
||||
announcement = 'Free shipping on orders over $100',
|
||||
announcementUrl,
|
||||
showAnnouncement = 'yes',
|
||||
}) => {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Whitespace-only copy counts as empty. While the field is edited inline the
|
||||
// editor supplies a node rather than a string — it can't be inspected for
|
||||
// emptiness, which is what the explicit toggle is for.
|
||||
const hasAnnouncementText =
|
||||
typeof announcement === 'string'
|
||||
? announcement.trim() !== ''
|
||||
: Boolean(announcement);
|
||||
|
||||
const showAnnouncementBar = showAnnouncement !== 'no' && hasAnnouncementText;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Sits above the sticky nav, so it scrolls away on its own. */}
|
||||
{showAnnouncementBar && (
|
||||
<div className="bg-muted text-foreground">
|
||||
<div className="max-w-screen-2xl mx-auto flex h-9 items-center justify-center px-8 text-center text-xs">
|
||||
{announcementUrl ? (
|
||||
<Link
|
||||
href={announcementUrl}
|
||||
className="underline-offset-2 hover:underline"
|
||||
>
|
||||
{announcement}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{announcement}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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-14">
|
||||
{/* Logo */}
|
||||
<Logo src={logoUrl} storeName={storeName} />
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center gap-x-8 text-sm">
|
||||
<ShopMenu />
|
||||
{links.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.url}
|
||||
className="text-foreground hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center">
|
||||
<SearchDialog />
|
||||
<AccountMenu />
|
||||
<CartIcon />
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<Button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? (
|
||||
<RiCloseLine className="size-5" />
|
||||
) : (
|
||||
<RiMenu3Line className="size-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{menuOpen && (
|
||||
<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">
|
||||
<ShopMenu mobile onNavigate={() => setMenuOpen(false)} />
|
||||
{links.map((link, index) => (
|
||||
<Link
|
||||
key={index}
|
||||
href={link.url}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="text-foreground hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CartDrawer />
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { RiImageLine } from '@remixicon/react';
|
||||
import type { Customer, CustomerOrder } from '@/services/shopify/customer';
|
||||
|
||||
interface OrderHistoryProps {
|
||||
customer: Customer;
|
||||
}
|
||||
|
||||
const formatMoney = (amount: string, currencyCode: string) => {
|
||||
const value = parseFloat(amount);
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(value);
|
||||
} catch {
|
||||
return `$${value.toFixed(2)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
const StatusPill: React.FC<{ label?: string | null }> = ({ label }) => {
|
||||
if (!label) return null;
|
||||
return (
|
||||
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
{label.replace(/_/g, ' ').toLowerCase()}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const OrderHistory: React.FC<OrderHistoryProps> = ({ customer }) => {
|
||||
const orders: CustomerOrder[] =
|
||||
customer.orders?.edges.map((edge) => edge.node) ?? [];
|
||||
|
||||
// The Storefront API has no standalone order-by-id query for customers, so
|
||||
// the detail is rendered from the order already loaded in this list.
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
orders[0]?.id ?? null
|
||||
);
|
||||
const selected = orders.find((order) => order.id === selectedId) ?? null;
|
||||
|
||||
if (orders.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You haven't placed any orders yet.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-5">
|
||||
{/* List */}
|
||||
<div className="lg:col-span-2">
|
||||
<h2 className="mb-3 text-sm text-muted-foreground">Orders</h2>
|
||||
<ul className="flex flex-col">
|
||||
{orders.map((order) => {
|
||||
const isSelected = order.id === selectedId;
|
||||
|
||||
return (
|
||||
<li key={order.id}>
|
||||
<button
|
||||
onClick={() => setSelectedId(order.id)}
|
||||
aria-current={isSelected}
|
||||
className={`flex w-full items-baseline justify-between gap-x-4 rounded-md px-3 py-3 text-left transition-colors ${
|
||||
isSelected ? 'bg-secondary' : 'hover:bg-secondary/60'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
Order #{order.orderNumber}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{formatDate(order.processedAt)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-sm tabular-nums tracking-tight text-foreground">
|
||||
{formatMoney(
|
||||
order.currentTotalPrice.amount,
|
||||
order.currentTotalPrice.currencyCode
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Detail — same screen, no navigation */}
|
||||
<div className="lg:col-span-3">
|
||||
{selected && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2">
|
||||
<h2 className="text-lg text-foreground">
|
||||
Order #{selected.orderNumber}
|
||||
</h2>
|
||||
<span className="font-mono text-base tabular-nums tracking-tight text-foreground">
|
||||
{formatMoney(
|
||||
selected.currentTotalPrice.amount,
|
||||
selected.currentTotalPrice.currencyCode
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Placed {formatDate(selected.processedAt)}
|
||||
</span>
|
||||
<StatusPill label={selected.financialStatus} />
|
||||
<StatusPill label={selected.fulfillmentStatus} />
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 flex flex-col gap-4">
|
||||
{selected.lineItems.edges.map(({ node }, index) => (
|
||||
<li key={index} className="flex items-start gap-x-3">
|
||||
<div className="h-16 w-16 shrink-0 overflow-hidden bg-zinc-100">
|
||||
{node.variant?.image ? (
|
||||
<Image
|
||||
src={node.variant.image.url}
|
||||
alt={node.variant.image.altText || node.title}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-400">
|
||||
<RiImageLine className="size-5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{node.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Qty {node.quantity}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{selected.statusUrl && (
|
||||
<a
|
||||
href={selected.statusUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-6 inline-block text-sm text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
View order status
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderHistory;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { FileText } from 'lucide-react';
|
||||
import PolicyBody, {
|
||||
type PolicyBodyProps,
|
||||
} from '@/components/shopify/policy-body';
|
||||
import { POLICY_HANDLES } from '@/hooks/use-shopify-policies';
|
||||
|
||||
const policyBodyEditor: ComponentConfig<PolicyBodyProps> = {
|
||||
label: 'Policy',
|
||||
icon: <FileText size={16} />,
|
||||
category: 'content',
|
||||
defaultProps: {
|
||||
handle: '',
|
||||
title: '',
|
||||
notFoundMessage: 'This policy has not been published yet.',
|
||||
},
|
||||
fields: {
|
||||
handle: {
|
||||
label: 'Policy',
|
||||
type: 'select',
|
||||
// Empty follows the `[handle]` route segment on /policies/[handle].
|
||||
options: [
|
||||
{ label: 'Follow the page URL', value: '' },
|
||||
...POLICY_HANDLES.map((handle) => ({
|
||||
label: handle.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||
value: handle,
|
||||
})),
|
||||
],
|
||||
},
|
||||
title: {
|
||||
label: 'Title override',
|
||||
type: 'text',
|
||||
placeholder: "Leave empty to use the policy's own title",
|
||||
contentEditable: true,
|
||||
},
|
||||
notFoundMessage: {
|
||||
label: 'Not-found message',
|
||||
type: 'text',
|
||||
contentEditable: true,
|
||||
},
|
||||
},
|
||||
render: (props) => <PolicyBody {...props} />,
|
||||
};
|
||||
|
||||
export default policyBodyEditor;
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { getShopPolicy, type ShopPolicy } from '@/hooks/use-shopify-policies';
|
||||
|
||||
export interface PolicyBodyProps {
|
||||
/**
|
||||
* Pins the block to one policy. Left empty, it reads the `[handle]` segment,
|
||||
* which is what the `/policies/[handle]` template does.
|
||||
*/
|
||||
handle?: string;
|
||||
/** Overrides the policy's own title. Empty falls back to Shopify's. */
|
||||
title?: string;
|
||||
notFoundMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy copy is authored in the Shopify admin, not here — the editor only
|
||||
* chooses which policy to show and can override the heading.
|
||||
*/
|
||||
const PolicyBody: React.FC<PolicyBodyProps> = ({
|
||||
handle: handleProp,
|
||||
title: titleProp,
|
||||
notFoundMessage = 'This policy has not been published yet.',
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const handle = handleProp || (params?.handle as string | undefined);
|
||||
|
||||
const [policy, setPolicy] = useState<ShopPolicy | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!handle) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
getShopPolicy(handle)
|
||||
.then((result) => {
|
||||
if (!cancelled) setPolicy(result);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPolicy(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handle]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
{titleProp || policy?.title || 'Policy'}
|
||||
</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-8 animate-pulse space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="h-4 bg-zinc-100" />
|
||||
))}
|
||||
</div>
|
||||
) : policy ? (
|
||||
<div
|
||||
className="policy-body mt-8 text-[15px] leading-7 text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: policy.body }}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-8 text-sm text-muted-foreground">
|
||||
{notFoundMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolicyBody;
|
||||
@@ -1,78 +1,119 @@
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { Typography } from "@/components/Typography";
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { truncate } from '@/lib/utils';
|
||||
|
||||
type ProductImage = { url: string; altText?: string };
|
||||
type ProductPrice = { amount: string; currencyCode: string };
|
||||
|
||||
export type ProductCardData = {
|
||||
id: string;
|
||||
handle: string;
|
||||
title: string;
|
||||
images?: { edges?: Array<{ node: ProductImage }> };
|
||||
priceRange?: { minVariantPrice?: ProductPrice };
|
||||
compareAtPriceRange?: { minVariantPrice?: ProductPrice };
|
||||
};
|
||||
|
||||
function format(price: ProductPrice) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount));
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string | null;
|
||||
}
|
||||
|
||||
export function ProductCard({
|
||||
product,
|
||||
aspect = "portrait",
|
||||
}: {
|
||||
product: ProductCardData;
|
||||
aspect?: "portrait" | "square" | "landscape";
|
||||
}) {
|
||||
const image = product.images?.edges?.[0]?.node;
|
||||
const price = product.priceRange?.minVariantPrice;
|
||||
const compare = product.compareAtPriceRange?.minVariantPrice;
|
||||
const onSale =
|
||||
price && compare && parseFloat(compare.amount) > parseFloat(price.amount);
|
||||
interface ProductPrice {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
const aspectClass: Record<string, string> = {
|
||||
portrait: "aspect-[4/5]",
|
||||
square: "aspect-square",
|
||||
landscape: "aspect-[4/3]",
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
title: string;
|
||||
price: ProductPrice;
|
||||
availableForSale: boolean;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
handle: string;
|
||||
images: {
|
||||
edges: Array<{
|
||||
node: ProductImage;
|
||||
}>;
|
||||
};
|
||||
priceRange: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
compareAtPriceRange?: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
variants: {
|
||||
edges: Array<{
|
||||
node: ProductVariant;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
}
|
||||
|
||||
const ProductCard: React.FC<ProductCardProps> = ({ product }) => {
|
||||
const firstImage = product.images.edges[0]?.node;
|
||||
const price = product.priceRange.minVariantPrice;
|
||||
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
|
||||
const hasDiscount =
|
||||
compareAtPrice &&
|
||||
parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
|
||||
const firstVariant = product.variants.edges[0]?.node;
|
||||
const isAvailable = firstVariant?.availableForSale || false;
|
||||
|
||||
const formatPrice = (amount: string) => {
|
||||
return `$${parseFloat(amount).toFixed(2)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/products/${product.handle}`} className="group block">
|
||||
<div
|
||||
className={`relative w-full overflow-hidden rounded-md bg-muted ${aspectClass[aspect]}`}
|
||||
>
|
||||
{image ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.altText || product.title}
|
||||
className="h-full w-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
|
||||
<Link
|
||||
href={`/products/${product.handle}`}
|
||||
className="group block h-full"
|
||||
>
|
||||
{/* Product Image */}
|
||||
<div className="relative aspect-square overflow-hidden">
|
||||
{firstImage ? (
|
||||
<Image
|
||||
src={firstImage.url}
|
||||
alt={firstImage.altText || product.title}
|
||||
fill
|
||||
sizes="(min-width: 1280px) 20vw, (min-width: 1024px) 25vw, (min-width: 640px) 33vw, 50vw"
|
||||
className="object-contain transition-transform duration-500 group-hover:scale-[1.04]"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 flex items-start justify-between gap-3">
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
className="font-medium tracking-tight text-foreground"
|
||||
>
|
||||
{product.title}
|
||||
</Typography>
|
||||
{price ? (
|
||||
<div className="flex flex-col items-end text-sm">
|
||||
{onSale && compare ? (
|
||||
<span className="text-xs text-muted-foreground line-through">
|
||||
{format(compare)}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="font-medium">{format(price)}</span>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-zinc-300">
|
||||
<i className="ri-image-line text-8xl"></i>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{hasDiscount && compareAtPrice && (
|
||||
<span className="absolute top-3 left-3 text-[11px] font-mono tracking-widest text-rose-600">
|
||||
SALE
|
||||
</span>
|
||||
)}
|
||||
|
||||
{!isAvailable && (
|
||||
<span className="absolute top-3 right-3 text-[11px] font-mono tracking-widest text-muted-foreground">
|
||||
SOLD OUT
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<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="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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ProductCard;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import type { ShopifyProduct } from '@reacteditor/plugin-shopify';
|
||||
import { Tag } from 'lucide-react';
|
||||
import ProductDetail from '@/components/shopify/product-detail';
|
||||
|
||||
export type ProductDetailBlockProps = {
|
||||
product?: ShopifyProduct | null;
|
||||
addToCartLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* On `/products/[handle]` leave `product` empty so the block follows the route;
|
||||
* the picker then only chooses what the editor previews. Pinning a product
|
||||
* turns it into a featured-product block usable on any page.
|
||||
*/
|
||||
const productDetailEditor: ComponentConfig<ProductDetailBlockProps> = {
|
||||
label: 'Product page',
|
||||
icon: <Tag size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
product: null,
|
||||
addToCartLabel: 'Add to Cart',
|
||||
},
|
||||
fields: {
|
||||
product: {
|
||||
label: 'Product',
|
||||
type: 'shopifyProduct',
|
||||
} as any,
|
||||
addToCartLabel: {
|
||||
label: 'Add to cart label',
|
||||
type: 'text',
|
||||
contentEditable: true,
|
||||
},
|
||||
},
|
||||
render: ({ product, addToCartLabel }) => (
|
||||
<ProductDetail handle={product?.handle} addToCartLabel={addToCartLabel} />
|
||||
),
|
||||
};
|
||||
|
||||
export default productDetailEditor;
|
||||
@@ -1,3 +1,3 @@
|
||||
import ProductDetail from './product-detail/index.tsx';
|
||||
import ProductDetail from './product-detail/index';
|
||||
|
||||
export default ProductDetail;
|
||||
export default ProductDetail;
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useProduct, type Product } from '@/hooks/use-shopify-products';
|
||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
||||
import { useShopifyCart } from '@/hooks/use-shopify-cart';
|
||||
import { useShopifyCart, redirectToCheckout } from '@/hooks/use-shopify-cart';
|
||||
import ProductDetailGallery from './product-detail-gallery';
|
||||
import ProductDetailInfo from './product-detail-info';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
EmptyContent,
|
||||
} from '@/components/ui/empty';
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
@@ -33,28 +29,36 @@ interface ProductVariant {
|
||||
}>;
|
||||
image?: {
|
||||
url: string;
|
||||
altText?: string;
|
||||
};
|
||||
altText?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type { Product };
|
||||
|
||||
interface ProductDetailProps {
|
||||
handle?: string;
|
||||
addToCartLabel?: string;
|
||||
}
|
||||
|
||||
const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) => {
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = handleProp || routeHandle || '';
|
||||
const { addItem, openCart } = useShopifyCart();
|
||||
const ProductDetail: React.FC<ProductDetailProps> = ({
|
||||
handle: handleProp,
|
||||
addToCartLabel = 'Add to Cart',
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const handle = handleProp || (params?.handle as string);
|
||||
const { addItem, openCart, checkoutUrl } = useShopifyCart();
|
||||
|
||||
const { product, loading, error } = useProduct(handle);
|
||||
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(null);
|
||||
const [selectedOptions, setSelectedOptions] = useState<Record<string, string>>({});
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
||||
null
|
||||
);
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
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(() => {
|
||||
@@ -64,38 +68,47 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
||||
setSelectedVariant(firstVariant);
|
||||
|
||||
const initialOptions: Record<string, string> = {};
|
||||
firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => {
|
||||
initialOptions[option.name] = option.value;
|
||||
});
|
||||
firstVariant.selectedOptions.forEach(
|
||||
(option: { name: string; value: string }) => {
|
||||
initialOptions[option.name] = option.value;
|
||||
}
|
||||
);
|
||||
setSelectedOptions(initialOptions);
|
||||
}
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// A value is available if some in-stock variant carries it alongside the
|
||||
// other currently-selected options. Options the shopper hasn't chosen yet
|
||||
// act as wildcards, so nothing is struck through before a full selection.
|
||||
const isOptionValueAvailable = (optionName: string, value: string) => {
|
||||
const variants = product?.variants.edges ?? [];
|
||||
if (variants.length === 0) return true;
|
||||
|
||||
return variants.some(({ node }) => {
|
||||
if (!node.availableForSale) return false;
|
||||
|
||||
return node.selectedOptions.every((option) => {
|
||||
if (option.name === optionName) return option.value === value;
|
||||
const selected = selectedOptions[option.name];
|
||||
return selected === undefined || selected === option.value;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleOptionChange = (optionName: string, value: string) => {
|
||||
const newOptions = { ...selectedOptions, [optionName]: value };
|
||||
setSelectedOptions(newOptions);
|
||||
|
||||
// Find matching variant
|
||||
const matchingVariant = product?.variants.edges.find(({ node }) => {
|
||||
return node.selectedOptions.every(option =>
|
||||
newOptions[option.name] === option.value
|
||||
return node.selectedOptions.every(
|
||||
(option) => newOptions[option.name] === option.value
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,89 +126,101 @@ const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) =>
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !handle || !product) {
|
||||
if (error && handle && !loading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Product not found</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
Go back
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// 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>
|
||||
<Skeleton className="aspect-square w-full mb-4" />
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square w-full" />
|
||||
))}
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-8 w-3/4" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
{/* Product Info Skeleton */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
if (error || !product) {
|
||||
return (
|
||||
<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>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/shop">Shop</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}
|
||||
/>
|
||||
<Empty className="min-h-[400px]">
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Product Not Found</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{error || 'The requested product could not be found.'}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={() => window.history.back()} variant="outline">
|
||||
Go Back
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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}
|
||||
isOptionValueAvailable={isOptionValueAvailable}
|
||||
loading={addingToCart}
|
||||
buyingNow={buyingNow}
|
||||
addToCartLabel={addToCartLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,212 @@
|
||||
import React from 'react';
|
||||
'use client';
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { RiCloseLine } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
altText?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
interface ProductDetailGalleryProps {
|
||||
images: ProductImage[];
|
||||
selectedImageIndex?: number;
|
||||
onImageSelect?: (index: number) => void;
|
||||
}
|
||||
|
||||
const ProductDetailGallery: React.FC<ProductDetailGalleryProps> = ({
|
||||
images,
|
||||
selectedImageIndex = 0,
|
||||
onImageSelect
|
||||
}) => {
|
||||
const selectedImage = selectedImageIndex;
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// Touch already scrolls natively; this adds click-and-drag for pointers that
|
||||
// don't (mouse at mobile widths), suspending snap so the drag stays smooth.
|
||||
const drag = useRef<{ startX: number; startScroll: number } | null>(null);
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType === 'touch') return;
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
drag.current = { startX: event.clientX, startScroll: scroller.scrollLeft };
|
||||
scroller.style.scrollSnapType = 'none';
|
||||
};
|
||||
|
||||
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!drag.current || !scroller) return;
|
||||
|
||||
event.preventDefault();
|
||||
scroller.scrollLeft =
|
||||
drag.current.startScroll - (event.clientX - drag.current.startX);
|
||||
};
|
||||
|
||||
const endDrag = () => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!drag.current || !scroller) return;
|
||||
|
||||
drag.current = null;
|
||||
// Restoring snap lets the browser settle on the nearest slide.
|
||||
scroller.style.scrollSnapType = '';
|
||||
};
|
||||
|
||||
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>
|
||||
{/* Main Image */}
|
||||
<div className="aspect-square bg-muted rounded-lg overflow-hidden mb-4">
|
||||
{images.length > 0 ? (
|
||||
<img
|
||||
src={images[selectedImage].url}
|
||||
alt={images[selectedImage].altText || 'Product image'}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
<i className="ri-image-line text-6xl"></i>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
{/* Swipeable carousel on mobile, grid from sm up */}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={handleScroll}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerLeave={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
className="flex snap-x snap-mandatory gap-3 overflow-x-auto no-scrollbar touch-pan-x sm:grid sm:grid-cols-2 sm:touch-auto 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 pointer-events-none sm:pointer-events-auto sm:shrink sm:cursor-zoom-in ${
|
||||
isSingle ? 'sm:col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={image.url}
|
||||
alt={image.altText || 'Product image'}
|
||||
fill
|
||||
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 50vw, 100vw"
|
||||
priority={index === 0}
|
||||
draggable={false}
|
||||
className="object-cover select-none"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Image Thumbnails */}
|
||||
{images.length > 1 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{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-lg overflow-hidden border-2 transition-colors ${
|
||||
selectedImage === index
|
||||
? 'border-foreground'
|
||||
: 'border-border hover:border-muted-foreground'
|
||||
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"
|
||||
>
|
||||
<Image
|
||||
src={zoomedImage.url}
|
||||
alt={zoomedImage.altText || 'Product image'}
|
||||
// Shopify gives us the intrinsic size; the square fallback only
|
||||
// reserves space until CSS scales it down to fit the overlay.
|
||||
width={zoomedImage.width || 1600}
|
||||
height={zoomedImage.height || 1600}
|
||||
sizes="100vw"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
// w/h-auto keeps the box at the image's own ratio; without it the
|
||||
// width+height attributes make both axes definite and the element
|
||||
// stretches to the overlay, swallowing backdrop clicks that close it.
|
||||
className="max-h-full max-w-full w-auto h-auto object-contain"
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={close}
|
||||
variant="ghost"
|
||||
size="icon-lg"
|
||||
aria-label="Close"
|
||||
className="absolute top-4 right-4 rounded-full bg-background shadow-sm hover:bg-secondary"
|
||||
>
|
||||
<RiCloseLine size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailGallery;
|
||||
export default ProductDetailGallery;
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Product, ProductVariant } from './index.tsx';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { RiSubtractLine, RiAddLine } from '@remixicon/react';
|
||||
import ShopPayButton from '@/components/shopify/shop-pay-button';
|
||||
import type { ProductOption, ProductOptionValue } from '@/hooks/use-shopify-products';
|
||||
import { isSwatchOptionName, swatchColorForName } from '@/config/swatches';
|
||||
import { isDefaultTitleOption } from '@/services/shopify/catalog';
|
||||
|
||||
interface ProductPrice {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
interface ProductVariant {
|
||||
id: string;
|
||||
title: string;
|
||||
price: ProductPrice;
|
||||
availableForSale: boolean;
|
||||
selectedOptions: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
descriptionHtml?: string;
|
||||
handle: string;
|
||||
priceRange: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
compareAtPriceRange?: {
|
||||
minVariantPrice: ProductPrice;
|
||||
};
|
||||
options: ProductOption[];
|
||||
}
|
||||
|
||||
export interface ProductFeature {
|
||||
icon: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProductDetailInfoProps {
|
||||
product: Product;
|
||||
@@ -11,10 +50,31 @@ interface ProductDetailInfoProps {
|
||||
quantity: number;
|
||||
setQuantity: (quantity: number) => void;
|
||||
handleAddToCart: () => void;
|
||||
handleBuyNow?: () => void;
|
||||
onOptionChange: (optionName: string, value: string) => void;
|
||||
/** Whether an option value still has an in-stock variant behind it. */
|
||||
isOptionValueAvailable?: (optionName: string, value: string) => boolean;
|
||||
loading?: boolean;
|
||||
buyingNow?: boolean;
|
||||
addToCartLabel?: string;
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -22,137 +82,201 @@ const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
||||
quantity,
|
||||
setQuantity,
|
||||
handleAddToCart,
|
||||
handleBuyNow,
|
||||
onOptionChange,
|
||||
isOptionValueAvailable,
|
||||
loading = false,
|
||||
buyingNow = false,
|
||||
addToCartLabel = 'Add to Cart',
|
||||
}) => {
|
||||
const formatPrice = (price: { amount: string; currencyCode: string }) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(parseFloat(price.amount));
|
||||
const formatPrice = (amount: string) => {
|
||||
return `$${parseFloat(amount).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
|
||||
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
|
||||
const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
|
||||
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.
|
||||
// Single-SKU products expose a synthetic `Title: Default Title` option — drop it.
|
||||
const orderedOptions = [...(product.options ?? [])]
|
||||
.filter((option) => !isDefaultTitleOption(option))
|
||||
.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>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4 font-heading">
|
||||
<h1 className="text-2xl md:text-3xl font-normal text-foreground">
|
||||
{product.title}
|
||||
</h1>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-center space-x-4 mb-6">
|
||||
<span className="text-2xl font-bold text-foreground">
|
||||
{formatPrice(price)}
|
||||
<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-xl text-muted-foreground line-through">
|
||||
{formatPrice(compareAtPrice)}
|
||||
</span>
|
||||
<Badge variant="destructive">
|
||||
{Math.round(((parseFloat(compareAtPrice.amount) - parseFloat(price.amount)) / parseFloat(compareAtPrice.amount)) * 100)}% OFF
|
||||
</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;
|
||||
const isSoldOut =
|
||||
isOptionValueAvailable?.(option.name, value.name) === false;
|
||||
|
||||
if (isSwatch) {
|
||||
const { background, image } = swatchStyle(value);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={value.id}
|
||||
onClick={() => onOptionChange(option.name, value.name)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={value.name}
|
||||
aria-pressed={isSelected}
|
||||
title={
|
||||
isSoldOut ? `${value.name} — out of stock` : value.name
|
||||
}
|
||||
data-available={!isSoldOut}
|
||||
className={`rounded-full bg-cover bg-center hover:bg-transparent ${
|
||||
isSelected
|
||||
? 'ring-2 ring-foreground ring-offset-2'
|
||||
: 'ring-1 ring-border hover:ring-foreground/40'
|
||||
} ${isSoldOut ? 'option-unavailable text-foreground/60 opacity-60' : ''}`}
|
||||
style={{
|
||||
// With no colour and no image the circle would be fully
|
||||
// transparent, leaving just a hairline ring that
|
||||
// antialiases unevenly and reads as a speckled border.
|
||||
// A neutral fill makes the initial-letter fallback look
|
||||
// deliberate. Set inline so it beats the ghost variant's
|
||||
// hover background.
|
||||
backgroundColor:
|
||||
background ??
|
||||
(image ? undefined : 'var(--color-muted)'),
|
||||
backgroundImage: image ? `url(${image})` : undefined,
|
||||
}}
|
||||
>
|
||||
{!background && !image && (
|
||||
<span className="text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{value.name.at(0)}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={value.id}
|
||||
onClick={() => onOptionChange(option.name, value.name)}
|
||||
variant="outline"
|
||||
aria-pressed={isSelected}
|
||||
title={isSoldOut ? `${value.name} — out of stock` : undefined}
|
||||
className={`min-w-14 px-5 font-normal shadow-none ${
|
||||
isSelected
|
||||
? 'border-foreground text-foreground'
|
||||
: 'text-muted-foreground hover:border-foreground hover:text-foreground'
|
||||
} ${isSoldOut ? 'option-unavailable text-muted-foreground/60' : ''}`}
|
||||
>
|
||||
{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}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Decrease quantity"
|
||||
className="h-full rounded-none rounded-l-md"
|
||||
>
|
||||
<RiSubtractLine size={16} />
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm tabular-nums">
|
||||
{quantity}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Increase quantity"
|
||||
className="h-full rounded-none rounded-r-md"
|
||||
>
|
||||
<RiAddLine size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!isAvailable || loading}
|
||||
className="flex-1 h-11"
|
||||
>
|
||||
{loading && <Loader size={16} />}
|
||||
{isAvailable ? addToCartLabel : 'Out of Stock'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cart permalink into Shop Pay; falls back to the cart checkout URL. */}
|
||||
<ShopPayButton
|
||||
className="mt-3"
|
||||
variants={
|
||||
selectedVariant ? [{ id: selectedVariant.id, quantity }] : []
|
||||
}
|
||||
disabled={!isAvailable || buyingNow}
|
||||
loading={buyingNow}
|
||||
onFallbackClick={handleBuyNow}
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
{product.description && (
|
||||
<div className="text-muted-foreground mb-8 text-lg leading-relaxed">
|
||||
{(product.descriptionHtml || product.description) && (
|
||||
<div className="mt-10 text-sm leading-6 text-foreground product-description">
|
||||
{product.descriptionHtml ? (
|
||||
<div dangerouslySetInnerHTML={{ __html: product.descriptionHtml }} />
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
|
||||
/>
|
||||
) : (
|
||||
<p>{product.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product Options */}
|
||||
{product.options.map(option => (
|
||||
<div key={option.id} className="mb-6">
|
||||
<label className="block text-sm font-semibold text-foreground mb-2">
|
||||
{option.name}
|
||||
</label>
|
||||
<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'}
|
||||
>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-semibold text-foreground mb-2">
|
||||
Quantity
|
||||
</label>
|
||||
<div className="flex items-center border border-border rounded-lg w-fit">
|
||||
<Button
|
||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<i className="ri-subtract-line"></i>
|
||||
</Button>
|
||||
<span className="w-10 text-center font-semibold">{quantity}</span>
|
||||
<Button
|
||||
onClick={() => setQuantity(quantity + 1)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
>
|
||||
<i className="ri-add-line"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<Button
|
||||
onClick={handleAddToCart}
|
||||
disabled={!selectedVariant?.availableForSale || loading}
|
||||
size="lg"
|
||||
className="w-full text-lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Adding...</span>
|
||||
</span>
|
||||
) : selectedVariant?.availableForSale ? (
|
||||
'Add to Cart'
|
||||
) : (
|
||||
'Out of Stock'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-8 pt-8 border-t border-border">
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-truck-line"></i>
|
||||
<span>Free shipping on orders over $100</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-arrow-go-back-line"></i>
|
||||
<span>30-day return policy</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<i className="ri-secure-payment-line"></i>
|
||||
<span>Secure payment</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailInfo;
|
||||
export default ProductDetailInfo;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Package } from "lucide-react";
|
||||
import { ProductDetailsView, type ProductDetailsProps } from "@/components/shopify/product-details";
|
||||
|
||||
const productDetailsEditor: ComponentConfig<ProductDetailsProps> = {
|
||||
label: "Product details",
|
||||
icon: <Package size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: { product: null },
|
||||
fields: { product: { label: "Product", type: "shopifyProduct" } as any },
|
||||
render: (props) => <ProductDetailsView {...props} />,
|
||||
};
|
||||
|
||||
export default productDetailsEditor;
|
||||
@@ -1,221 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouteSegment } from "@/hooks/use-route-segment";
|
||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
||||
import { useProduct } from "@/hooks/use-shopify-products";
|
||||
import { useShopifyCart } from "@/hooks/use-shopify-cart";
|
||||
import { Typography } from "@/components/Typography";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Loader } from "@/components/ui/loader";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
|
||||
export type ProductDetailsProps = {
|
||||
product: ShopifyProduct | null;
|
||||
};
|
||||
|
||||
export function ProductDetailsView({ product: selected }: ProductDetailsProps) {
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = selected?.handle ?? routeHandle ?? null;
|
||||
const { product, loading } = useProduct(handle);
|
||||
const cart = useShopifyCart();
|
||||
const [activeImage, setActiveImage] = useState(0);
|
||||
const [variant, setVariant] = useState<any>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (product?.variants?.edges?.length) {
|
||||
setVariant(product.variants.edges[0].node);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
if (!handle || loading || !product) {
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-20">
|
||||
<Container className="grid grid-cols-1 gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="aspect-[4/5] w-full" />
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-square w-20 flex-shrink-0" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6">
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-6 w-1/4" />
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-10 w-16 rounded-md" />
|
||||
<Skeleton className="h-10 w-16 rounded-md" />
|
||||
<Skeleton className="h-10 w-16 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Skeleton className="h-11 w-32 rounded-md" />
|
||||
<Skeleton className="h-11 flex-1 rounded-md" />
|
||||
</div>
|
||||
<div className="space-y-2 border-t border-border pt-6">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const images = product.images?.edges?.map((e: any) => e.node) ?? [];
|
||||
const main = images[activeImage];
|
||||
const price = variant?.price ?? product.priceRange?.minVariantPrice;
|
||||
const formatted = price
|
||||
? new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currencyCode,
|
||||
}).format(parseFloat(price.amount))
|
||||
: null;
|
||||
|
||||
const onAdd = async () => {
|
||||
if (!variant) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
await cart.addItem(variant.id, quantity);
|
||||
cart.openCart();
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-20">
|
||||
<Container className="grid grid-cols-1 gap-10 md:grid-cols-2 md:gap-16">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="aspect-[4/5] w-full overflow-hidden rounded-md bg-muted">
|
||||
{main ? (
|
||||
<img
|
||||
src={main.url}
|
||||
alt={main.altText || product.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{images.length > 1 ? (
|
||||
<div className="flex gap-3 overflow-x-auto p-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
{images.map((img: any, i: number) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveImage(i)}
|
||||
className={cn(
|
||||
"aspect-square w-20 flex-shrink-0 overflow-hidden rounded-md transition-opacity",
|
||||
i === activeImage
|
||||
? "ring-2 ring-foreground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Typography variant="h2" as="h1">
|
||||
{product.title}
|
||||
</Typography>
|
||||
{formatted ? (
|
||||
<Typography variant="subtitle1" className="mt-3 text-foreground">
|
||||
{formatted}
|
||||
</Typography>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(product.options ?? []).map((opt: any) => (
|
||||
<div key={opt.id ?? opt.name}>
|
||||
<p className="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{opt.name}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{opt.values.map((val: string) => {
|
||||
const matching = product.variants.edges.find((e: any) =>
|
||||
e.node.selectedOptions?.some(
|
||||
(o: any) => o.name === opt.name && o.value === val,
|
||||
),
|
||||
);
|
||||
const selected = variant?.selectedOptions?.some(
|
||||
(o: any) => o.name === opt.name && o.value === val,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => matching && setVariant(matching.node)}
|
||||
className={cn(
|
||||
"min-w-12 rounded-md border px-4 py-2 text-sm transition-colors",
|
||||
selected
|
||||
? "border-foreground bg-foreground text-background"
|
||||
: "border-border hover:border-foreground",
|
||||
)}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<div className="flex items-center gap-3 rounded-md border border-border px-4 py-2">
|
||||
<button
|
||||
onClick={() => setQuantity((q) => Math.max(1, q - 1))}
|
||||
className="text-base hover:opacity-60"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-6 text-center text-sm">{quantity}</span>
|
||||
<button
|
||||
onClick={() => setQuantity((q) => q + 1)}
|
||||
className="text-base hover:opacity-60"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onAdd}
|
||||
disabled={!variant || adding}
|
||||
className="flex-1 rounded-md bg-foreground px-6 py-3 text-sm font-medium tracking-wide text-background transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{adding ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader size={16} />
|
||||
Adding…
|
||||
</span>
|
||||
) : (
|
||||
"Add to bag"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{product.description ? (
|
||||
<div className="border-t border-border pt-6">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Details
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-relaxed text-foreground/80">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetBody,
|
||||
AnimatePresence,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RiCloseLine, RiCheckLine } from '@remixicon/react';
|
||||
import { swatchColorForName } from '@/config/swatches';
|
||||
// Shape of a Storefront facet — identical for `search.productFilters` and
|
||||
// `collection.products.filters`, so both pages share this panel.
|
||||
export interface ProductFilterValue {
|
||||
id: string;
|
||||
label: string;
|
||||
count: number;
|
||||
/** JSON string accepted back as a `ProductFilter` input. */
|
||||
input: string;
|
||||
}
|
||||
|
||||
export interface ProductFilterFacet {
|
||||
id: string;
|
||||
label: string;
|
||||
type: 'LIST' | 'PRICE_RANGE' | 'BOOLEAN';
|
||||
values: ProductFilterValue[];
|
||||
}
|
||||
|
||||
interface ProductFiltersProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filters: ProductFilterFacet[];
|
||||
activeFilters: string[];
|
||||
onActiveFiltersChange: (filters: string[]) => void;
|
||||
}
|
||||
|
||||
// Colour facets render as swatches; everything else as a labelled list.
|
||||
const isColorFilter = (filter: ProductFilterFacet) =>
|
||||
/colou?r/i.test(filter.label) || filter.id.toLowerCase().includes('color');
|
||||
|
||||
const ProductFilters: React.FC<ProductFiltersProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
activeFilters,
|
||||
onActiveFiltersChange,
|
||||
}) => {
|
||||
const [priceMin, setPriceMin] = useState('');
|
||||
const [priceMax, setPriceMax] = useState('');
|
||||
|
||||
const listFilters = filters.filter((filter) => filter.type === 'LIST');
|
||||
const priceFilter = filters.find((filter) => filter.type === 'PRICE_RANGE');
|
||||
|
||||
const activeSet = new Set(activeFilters);
|
||||
// Price is rebuilt from the inputs rather than toggled, so track it apart.
|
||||
const activePriceInput = activeFilters.find((input) =>
|
||||
input.includes('"price"')
|
||||
);
|
||||
|
||||
const toggleValue = (value: ProductFilterValue) => {
|
||||
onActiveFiltersChange(
|
||||
activeSet.has(value.input)
|
||||
? activeFilters.filter((input) => input !== value.input)
|
||||
: [...activeFilters, value.input]
|
||||
);
|
||||
};
|
||||
|
||||
const applyPrice = () => {
|
||||
const min = parseFloat(priceMin);
|
||||
const max = parseFloat(priceMax);
|
||||
const withoutPrice = activeFilters.filter(
|
||||
(input) => !input.includes('"price"')
|
||||
);
|
||||
|
||||
if (Number.isNaN(min) && Number.isNaN(max)) {
|
||||
onActiveFiltersChange(withoutPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
const price: { min?: number; max?: number } = {};
|
||||
if (!Number.isNaN(min)) price.min = min;
|
||||
if (!Number.isNaN(max)) price.max = max;
|
||||
|
||||
onActiveFiltersChange([...withoutPrice, JSON.stringify({ price })]);
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setPriceMin('');
|
||||
setPriceMax('');
|
||||
onActiveFiltersChange([]);
|
||||
};
|
||||
|
||||
// Labels for the chips at the top of the panel.
|
||||
const activeChips = filters
|
||||
.flatMap((filter) => filter.values)
|
||||
.filter((value) => activeSet.has(value.input));
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange} side="left">
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<SheetContent className="w-full max-w-sm" showCloseButton={false}>
|
||||
<SheetHeader className="min-h-0 border-b-0 px-5 py-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<SheetTitle className="text-lg font-medium">Filters</SheetTitle>
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close filters"
|
||||
>
|
||||
<RiCloseLine size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody className="px-5">
|
||||
{/* Active selections */}
|
||||
{(activeChips.length > 0 || activePriceInput) && (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2">
|
||||
{activeChips.map((value) => (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => toggleValue(value)}
|
||||
className="flex items-center gap-x-1 rounded-md bg-secondary px-2 py-1 text-xs text-foreground"
|
||||
>
|
||||
{value.label}
|
||||
<RiCloseLine size={12} />
|
||||
</button>
|
||||
))}
|
||||
<Button
|
||||
onClick={clearAll}
|
||||
variant="link"
|
||||
className="h-auto px-0 text-xs font-normal text-muted-foreground"
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filters.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No filters available for these results.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{priceFilter && (
|
||||
<div className="mb-8">
|
||||
<h3 className="mb-3 text-base text-foreground">Price</h3>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={priceMin}
|
||||
onChange={(event) => setPriceMin(event.target.value)}
|
||||
placeholder="$ From"
|
||||
aria-label="Minimum price"
|
||||
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={priceMax}
|
||||
onChange={(event) => setPriceMax(event.target.value)}
|
||||
placeholder="$ To"
|
||||
aria-label="Maximum price"
|
||||
className="h-10 w-full rounded-md bg-secondary px-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Button
|
||||
onClick={applyPrice}
|
||||
size="icon"
|
||||
aria-label="Apply price range"
|
||||
className="shrink-0"
|
||||
>
|
||||
<RiCheckLine size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Facets */}
|
||||
{listFilters.map((filter) => (
|
||||
<div key={filter.id} className="mb-8">
|
||||
<h3 className="mb-3 text-base text-foreground">
|
||||
{filter.label}
|
||||
</h3>
|
||||
|
||||
{isColorFilter(filter) ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filter.values.map((value) => {
|
||||
const isActive = activeSet.has(value.input);
|
||||
const color = swatchColorForName(value.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => toggleValue(value)}
|
||||
title={`${value.label} (${value.count})`}
|
||||
aria-label={value.label}
|
||||
aria-pressed={isActive}
|
||||
className={`h-8 w-8 rounded-full transition-shadow ${
|
||||
isActive
|
||||
? 'ring-2 ring-foreground ring-offset-2'
|
||||
: 'ring-1 ring-border hover:ring-foreground/40'
|
||||
}`}
|
||||
style={{
|
||||
// A colourless swatch would otherwise be a fully
|
||||
// transparent circle behind a hairline ring,
|
||||
// which antialiases into a speckled border.
|
||||
backgroundColor: color ?? 'var(--color-muted)',
|
||||
}}
|
||||
>
|
||||
{!color && (
|
||||
<span className="text-[10px] font-medium uppercase text-muted-foreground">
|
||||
{value.label.at(0)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filter.values.map((value) => {
|
||||
const isActive = activeSet.has(value.input);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={value.id}
|
||||
onClick={() => toggleValue(value)}
|
||||
aria-pressed={isActive}
|
||||
className="flex items-center justify-between py-1.5 text-left text-sm text-foreground hover:text-muted-foreground"
|
||||
>
|
||||
<span>
|
||||
{value.label} ({value.count})
|
||||
</span>
|
||||
{isActive && <RiCheckLine size={16} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</SheetBody>
|
||||
</SheetContent>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductFilters;
|
||||
@@ -1,25 +1,40 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import {
|
||||
ProductRecommendationsView,
|
||||
type ProductRecommendationsProps,
|
||||
} from "@/components/shopify/product-recommendations";
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import type { ShopifyProduct } from '@reacteditor/plugin-shopify';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import ProductRecommendations from '@/components/shopify/product-recommendations';
|
||||
|
||||
const productRecommendationsEditor: ComponentConfig<ProductRecommendationsProps> = {
|
||||
label: "Product recommendations",
|
||||
icon: <LayoutGrid size={16} />,
|
||||
category: "commerce",
|
||||
export type ProductRecommendationsBlockProps = {
|
||||
title?: string;
|
||||
product?: ShopifyProduct | null;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
const productRecommendationsEditor: ComponentConfig<ProductRecommendationsBlockProps> =
|
||||
{
|
||||
label: 'Recommended products',
|
||||
icon: <Sparkles size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
title: 'You May Also Like',
|
||||
product: null,
|
||||
heading: "You Might Also Like",
|
||||
limit: 4,
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Source product", type: "shopifyProduct" } as any,
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 8 },
|
||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||
product: {
|
||||
label: 'Seed product',
|
||||
// Empty follows the `[handle]` route segment; Shopify picks the rest.
|
||||
type: 'shopifyProduct',
|
||||
} as any,
|
||||
limit: { label: 'Products shown', type: 'number', min: 2, max: 12 },
|
||||
},
|
||||
render: (props) => <ProductRecommendationsView {...props} />,
|
||||
};
|
||||
render: ({ title, product, limit }) => (
|
||||
<ProductRecommendations
|
||||
title={title}
|
||||
handle={product?.handle}
|
||||
limit={limit}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default productRecommendationsEditor;
|
||||
|
||||
@@ -1,75 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { ShopifyProduct } from '@reacteditor/field-shopify';
|
||||
import { useParams } from 'next/navigation';
|
||||
import {
|
||||
useProduct,
|
||||
useProductRecommendations,
|
||||
} from '@/hooks/use-shopify-products';
|
||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
||||
import { ProductCard } from './product-card';
|
||||
import ProductCard from './product-card';
|
||||
|
||||
export type ProductRecommendationsProps = {
|
||||
product: ShopifyProduct | null;
|
||||
heading: string;
|
||||
limit: number;
|
||||
};
|
||||
interface ProductRecommendationsProps {
|
||||
productId?: string;
|
||||
/**
|
||||
* Seeds recommendations from a specific product. Left empty, it reads the
|
||||
* `[handle]` segment, which is what the `/products/[handle]` template does.
|
||||
*/
|
||||
handle?: string;
|
||||
title?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const ProductRecommendations: React.FC<ProductRecommendationsProps> = ({
|
||||
productId: productIdProp,
|
||||
handle: handleProp,
|
||||
title = 'You May Also Like',
|
||||
limit = 4,
|
||||
}) => {
|
||||
const params = useParams();
|
||||
const handle = handleProp || (params?.handle as string | undefined);
|
||||
const { product } = useProduct(productIdProp ? null : (handle ?? null));
|
||||
const resolvedProductId = productIdProp || product?.id || '';
|
||||
|
||||
export function ProductRecommendationsView({
|
||||
product: selected,
|
||||
heading,
|
||||
limit,
|
||||
}: ProductRecommendationsProps) {
|
||||
const routeHandle = useRouteSegment();
|
||||
const handle = selected?.handle ?? routeHandle ?? null;
|
||||
const { product } = useProduct(handle);
|
||||
const { recommendations, loading, error } = useProductRecommendations(
|
||||
product?.id ?? null,
|
||||
resolvedProductId || null
|
||||
);
|
||||
|
||||
// Don't show section if we're not loading and have no recommendations
|
||||
if (!loading && (!recommendations || recommendations.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-muted py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-12 text-foreground font-heading">
|
||||
{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: limit }).map((_, index) => (
|
||||
<div key={index} className="bg-card rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-square bg-muted"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-6 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded mb-4"></div>
|
||||
<div className="h-12 bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">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, limit).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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ProductRecommendationsView;
|
||||
export default ProductRecommendations;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
RiEqualizerLine,
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
export interface ToolbarSortOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProductToolbarProps {
|
||||
totalCount?: number | null;
|
||||
onOpenFilters: () => void;
|
||||
sortOptions: ToolbarSortOption[];
|
||||
sortIndex: number;
|
||||
onSortChange: (index: number) => void;
|
||||
activeFilterCount?: number;
|
||||
}
|
||||
|
||||
// Filters trigger on the left, item count and sort menu on the right — shared
|
||||
// by the search results and collection pages.
|
||||
const ProductToolbar: React.FC<ProductToolbarProps> = ({
|
||||
totalCount,
|
||||
onOpenFilters,
|
||||
sortOptions,
|
||||
sortIndex,
|
||||
onSortChange,
|
||||
activeFilterCount = 0,
|
||||
}) => {
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
onClick={onOpenFilters}
|
||||
variant="ghost"
|
||||
className="gap-x-2 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
<RiEqualizerLine size={18} />
|
||||
Filters
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-foreground px-1.5 text-[11px] font-medium text-background">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-x-4">
|
||||
{typeof totalCount === 'number' && (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{totalCount} {totalCount === 1 ? 'Item' : 'Items'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Button
|
||||
onClick={() => setSortOpen((prev) => !prev)}
|
||||
variant="ghost"
|
||||
aria-expanded={sortOpen}
|
||||
className="gap-x-1 px-0 font-normal hover:bg-transparent"
|
||||
>
|
||||
Sort
|
||||
<RiArrowDownSLine size={16} />
|
||||
</Button>
|
||||
|
||||
{sortOpen && (
|
||||
<>
|
||||
{/* Click-away layer sits under the menu, above the page. */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setSortOpen(false)}
|
||||
/>
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-md border border-border bg-background py-1 shadow-md">
|
||||
{sortOptions.map((option, index) => (
|
||||
<button
|
||||
key={option.label}
|
||||
onClick={() => {
|
||||
onSortChange(index);
|
||||
setSortOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-left text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
{option.label}
|
||||
{index === sortIndex && <RiCheckLine size={16} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductToolbar;
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { GalleryHorizontalEnd } from "lucide-react";
|
||||
import { ProductsCarousel, type ProductsCarouselProps } from "@/components/shopify/products-carousel";
|
||||
|
||||
const productsCarouselEditor: ComponentConfig<ProductsCarouselProps> = {
|
||||
label: "Products carousel",
|
||||
icon: <GalleryHorizontalEnd size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
tagline: "New",
|
||||
heading: "Just dropped",
|
||||
subheading: "Fresh additions to the lineup.",
|
||||
limit: 12,
|
||||
slidesPerView: "4",
|
||||
ctaLabel: "Shop new",
|
||||
ctaHref: "",
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", type: "shopifyCollection" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 4, max: 24 },
|
||||
slidesPerView: {
|
||||
label: "Slides per view",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "2 per view", value: "2" },
|
||||
{ label: "3 per view", value: "3" },
|
||||
{ label: "4 per view", value: "4" },
|
||||
],
|
||||
},
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
},
|
||||
render: (props) => <ProductsCarousel {...props} />,
|
||||
};
|
||||
|
||||
export default productsCarouselEditor;
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
||||
import { getProducts } from "@/hooks/use-shopify-products";
|
||||
import { getCollectionProducts } from "@/hooks/use-shopify-collections";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Heading } from "@/components/Heading";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/components/ui/carousel";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
|
||||
export type ProductsCarouselProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
limit: number;
|
||||
slidesPerView: "2" | "3" | "4";
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
const basisClass: Record<ProductsCarouselProps["slidesPerView"], string> = {
|
||||
"2": "md:basis-1/2",
|
||||
"3": "md:basis-1/3",
|
||||
"4": "md:basis-1/4",
|
||||
};
|
||||
|
||||
export function ProductsCarousel({
|
||||
collection,
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
limit,
|
||||
slidesPerView,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
}: ProductsCarouselProps) {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
if (collection?.handle) {
|
||||
const data = await getCollectionProducts(collection.handle, {
|
||||
first: limit,
|
||||
});
|
||||
if (!cancelled) setProducts(data?.collection?.products ?? []);
|
||||
} else {
|
||||
const data = await getProducts({
|
||||
first: limit,
|
||||
sortKey: "CREATED_AT",
|
||||
reverse: true,
|
||||
});
|
||||
if (!cancelled) setProducts(data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setProducts([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [collection?.handle, limit]);
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<div className="mb-10 flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align="left"
|
||||
size="lg"
|
||||
maxWidth="max-w-xl"
|
||||
/>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
href={
|
||||
ctaHref ||
|
||||
(collection?.handle ? `/collections/${collection.handle}` : "/collections")
|
||||
}
|
||||
className="text-sm font-medium tracking-wide hover:opacity-70"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Carousel opts={{ align: "start", loop: true }}>
|
||||
<CarouselContent className="-ml-6">
|
||||
{(products.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => ({ id: `sk-${i}` }))
|
||||
: products
|
||||
).map((p: any) => (
|
||||
<CarouselItem
|
||||
key={p.id}
|
||||
className={`pl-6 basis-full sm:basis-1/2 ${basisClass[slidesPerView]}`}
|
||||
>
|
||||
{products.length === 0 ? (
|
||||
<div className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted" />
|
||||
) : (
|
||||
<ProductCard product={p} />
|
||||
)}
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious className="left-2 md:left-4" />
|
||||
<CarouselNext className="right-2 md:right-4" />
|
||||
</Carousel>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
import { ProductsGrid, type ProductsGridProps } from "@/components/shopify/products-grid";
|
||||
|
||||
const productsGridEditor: ComponentConfig<ProductsGridProps> = {
|
||||
label: "Products grid",
|
||||
icon: <LayoutGrid size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
collection: null,
|
||||
tagline: "Shop",
|
||||
heading: "Latest arrivals",
|
||||
subheading: "New pieces, fresh in this season.",
|
||||
columns: "4",
|
||||
limit: 8,
|
||||
ctaLabel: "View all",
|
||||
ctaHref: "",
|
||||
},
|
||||
fields: {
|
||||
collection: { label: "Collection", type: "shopifyCollection" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
||||
columns: {
|
||||
label: "Columns",
|
||||
type: "radio",
|
||||
options: [
|
||||
{ label: "3 columns", value: "3" },
|
||||
{ label: "4 columns", value: "4" },
|
||||
],
|
||||
},
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 24 },
|
||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
||||
ctaHref: { label: "CTA link", type: "text" },
|
||||
},
|
||||
render: (props) => <ProductsGrid {...props} />,
|
||||
};
|
||||
|
||||
export default productsGridEditor;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import type { ShopifyCollection } from "@reacteditor/field-shopify";
|
||||
import { getProducts } from "@/hooks/use-shopify-products";
|
||||
import { getCollectionProducts } from "@/hooks/use-shopify-collections";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type ProductsGridProps = {
|
||||
collection: ShopifyCollection | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
subheading: string;
|
||||
columns: "3" | "4";
|
||||
limit: number;
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
};
|
||||
|
||||
const colClass: Record<ProductsGridProps["columns"], string> = {
|
||||
"3": "grid-cols-2 md:grid-cols-3",
|
||||
"4": "grid-cols-2 md:grid-cols-3 lg:grid-cols-4",
|
||||
};
|
||||
|
||||
export function ProductsGrid({
|
||||
collection,
|
||||
tagline,
|
||||
heading,
|
||||
subheading,
|
||||
columns,
|
||||
limit,
|
||||
ctaLabel,
|
||||
ctaHref,
|
||||
}: ProductsGridProps) {
|
||||
const [products, setProducts] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
if (collection?.handle) {
|
||||
const data = await getCollectionProducts(collection.handle, {
|
||||
first: limit,
|
||||
});
|
||||
if (!cancelled) setProducts(data?.collection?.products ?? []);
|
||||
} else {
|
||||
const data = await getProducts({ first: limit, sortKey: "BEST_SELLING" });
|
||||
if (!cancelled) setProducts(data ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setProducts([]);
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [collection?.handle, limit]);
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<div className="mb-12 flex flex-col items-end justify-between gap-6 md:flex-row md:items-end">
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
subtitle={subheading}
|
||||
align="left"
|
||||
size="lg"
|
||||
maxWidth="max-w-xl"
|
||||
/>
|
||||
{ctaLabel ? (
|
||||
<Link
|
||||
href={ctaHref || (collection?.handle ? `/collections/${collection.handle}` : "/collections")}
|
||||
className="text-sm font-medium tracking-wide hover:opacity-70"
|
||||
>
|
||||
{ctaLabel} →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={`grid gap-x-6 gap-y-12 ${colClass[columns]}`}>
|
||||
{products.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-[4/5] w-full animate-pulse rounded-md bg-muted"
|
||||
/>
|
||||
))
|
||||
: products.map((p) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import type { ShopifyCollection } from '@reacteditor/plugin-shopify';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import Products from '@/components/shopify/products';
|
||||
|
||||
export type ProductsBlockProps = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
collection?: ShopifyCollection | null;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Column counts, gutters and the card design are fixed in `products.tsx`. The
|
||||
* editor picks *which* products appear and what the section says about them.
|
||||
*/
|
||||
const productsEditor: ComponentConfig<ProductsBlockProps> = {
|
||||
label: 'Product grid',
|
||||
icon: <LayoutGrid size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
title: 'Shopify Hydrogen Storefront',
|
||||
subtitle:
|
||||
'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
|
||||
collection: null,
|
||||
limit: 12,
|
||||
},
|
||||
fields: {
|
||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||
subtitle: { label: 'Subtitle', type: 'textarea', contentEditable: true },
|
||||
collection: {
|
||||
label: 'Collection',
|
||||
// Registered by createShopifyPlugin — a live search against the store.
|
||||
// Leave empty to show the newest products across the whole catalogue.
|
||||
type: 'shopifyCollection',
|
||||
} as any,
|
||||
limit: { label: 'Products shown', type: 'number', min: 2, max: 48 },
|
||||
},
|
||||
render: ({ title, subtitle, collection, limit }) => (
|
||||
<Products
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
limit={limit}
|
||||
collectionHandle={collection?.handle}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export default productsEditor;
|
||||
+92
-102
@@ -2,13 +2,14 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ProductCard from './product-card';
|
||||
import { getProducts } from '@/hooks/use-shopify-products';
|
||||
import { getProductsPage } from '@/hooks/use-shopify-products';
|
||||
import { getCollectionProductsPage } from '@/hooks/use-shopify-collections';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
|
||||
interface ProductImage {
|
||||
url: string;
|
||||
altText?: string;
|
||||
altText?: string | null;
|
||||
}
|
||||
|
||||
interface ProductPrice {
|
||||
@@ -48,22 +49,33 @@ interface Product {
|
||||
|
||||
interface ProductsProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
limit?: number;
|
||||
showLoadMore?: boolean;
|
||||
/**
|
||||
* Narrows the grid to one collection. Empty shows the newest products across
|
||||
* the whole catalogue, which is what the home page does.
|
||||
*/
|
||||
collectionHandle?: string;
|
||||
}
|
||||
|
||||
const Products: React.FC<ProductsProps> = ({
|
||||
title = "Our Products",
|
||||
const Products: React.FC<ProductsProps> = ({
|
||||
title = 'Shopify Hydrogen Storefront',
|
||||
subtitle = 'An agent-friendly Shopify storefront built with Next.js and Hydrogen.',
|
||||
limit = 12,
|
||||
showLoadMore = true
|
||||
showLoadMore = true,
|
||||
collectionHandle,
|
||||
}) => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMoreProducts, setHasMoreProducts] = useState(true);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
|
||||
const fetchProducts = async (currentProducts: Product[] = [], loadMore = false) => {
|
||||
// Paging is cursor-based: without `after`, Shopify returns the same first
|
||||
// page every time and "load more" appends nothing.
|
||||
const fetchProducts = async (loadMore = false) => {
|
||||
try {
|
||||
if (loadMore) {
|
||||
setLoadingMore(true);
|
||||
@@ -72,26 +84,32 @@ const Products: React.FC<ProductsProps> = ({
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const newProducts = await getProducts({
|
||||
first: limit,
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
// A pinned collection uses the collection query so its own ordering
|
||||
// applies; otherwise fall back to newest-first across the catalogue.
|
||||
const page = collectionHandle
|
||||
? await getCollectionProductsPage(collectionHandle, {
|
||||
first: limit,
|
||||
after: loadMore ? cursor : null,
|
||||
})
|
||||
: await getProductsPage({
|
||||
first: limit,
|
||||
after: loadMore ? cursor : null,
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true,
|
||||
});
|
||||
|
||||
setProducts((prev) => {
|
||||
if (!loadMore) return page.products;
|
||||
|
||||
const existingIds = new Set(prev.map((p) => p.id));
|
||||
return [
|
||||
...prev,
|
||||
...page.products.filter((p) => !existingIds.has(p.id)),
|
||||
];
|
||||
});
|
||||
|
||||
if (loadMore) {
|
||||
// Filter out products that already exist
|
||||
const existingIds = new Set(currentProducts.map(p => p.id));
|
||||
const uniqueNewProducts = newProducts.filter(p => !existingIds.has(p.id));
|
||||
|
||||
if (uniqueNewProducts.length === 0) {
|
||||
setHasMoreProducts(false);
|
||||
} else {
|
||||
setProducts(prev => [...prev, ...uniqueNewProducts]);
|
||||
}
|
||||
} else {
|
||||
setProducts(newProducts);
|
||||
setHasMoreProducts(newProducts.length === limit);
|
||||
}
|
||||
setCursor(page.endCursor);
|
||||
setHasMoreProducts(page.hasNextPage);
|
||||
} catch (err) {
|
||||
console.error('Error fetching products:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load products');
|
||||
@@ -103,37 +121,32 @@ const Products: React.FC<ProductsProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
}, [limit]);
|
||||
|
||||
const handleAddToCart = async (product: Product) => {
|
||||
// Here you would typically integrate with cart functionality
|
||||
console.log('Adding to cart:', product);
|
||||
};
|
||||
}, [limit, collectionHandle]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!loadingMore && hasMoreProducts) {
|
||||
fetchProducts(products, true);
|
||||
fetchProducts(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-12 font-heading">
|
||||
<div className="py-20">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<h2 className="text-center text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
<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-card rounded-lg shadow-md overflow-hidden animate-pulse">
|
||||
<div className="aspect-square bg-muted"></div>
|
||||
<div className="p-6">
|
||||
<div className="h-6 bg-muted rounded mb-2"></div>
|
||||
<div className="h-4 bg-muted rounded mb-4"></div>
|
||||
<div className="h-8 bg-muted rounded mb-4"></div>
|
||||
<div className="h-12 bg-muted rounded"></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>
|
||||
))}
|
||||
@@ -143,85 +156,62 @@ const Products: React.FC<ProductsProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error || products.length === 0) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="mx-auto flex max-w-md flex-col items-start gap-3 rounded-lg border border-border bg-foreground/[0.02] p-6">
|
||||
<p className="text-sm font-medium">Could not load products</p>
|
||||
<p className="font-mono text-xs leading-relaxed text-muted-foreground line-clamp-3">
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => fetchProducts()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-4xl font-bold mb-8 font-heading">
|
||||
<div className="py-20 bg-background">
|
||||
<div className="max-w-screen-2xl mx-auto px-8 text-center">
|
||||
<h2 className="text-2xl md:text-3xl font-normal max-w-3xl mx-auto text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="bg-muted border border-border rounded-lg p-8 max-w-md mx-auto">
|
||||
<i className="ri-shopping-bag-line text-4xl text-muted-foreground mb-4"></i>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
No Products Found
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Check back later or configure your Shopify store connection.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-12">
|
||||
{subtitle}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{error ||
|
||||
'Our curated collection is being prepared. Please check back shortly.'}
|
||||
</p>
|
||||
{error && (
|
||||
<Button
|
||||
onClick={() => fetchProducts()}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-5xl font-bold text-center mb-16 text-foreground font-heading">
|
||||
<div className="py-20">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<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-sm font-normal max-w-xl mx-auto text-muted-foreground mt-2.5 mb-16">
|
||||
{subtitle}
|
||||
</p>
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 mb-12">
|
||||
<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>
|
||||
|
||||
{/* Load More Button */}
|
||||
{showLoadMore && hasMoreProducts && (
|
||||
<div className="text-center">
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="font-heading"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<span className="flex items-center space-x-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
) : (
|
||||
'Load More Products'
|
||||
)}
|
||||
{loadingMore && <Loader size={16} />}
|
||||
{loadingMore ? 'Loading' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -230,4 +220,4 @@ const Products: React.FC<ProductsProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
export default Products;
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ComponentConfig } from "@reacteditor/core";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { RecommendedProductsView, type RecommendedProductsProps } from "@/components/shopify/recommended-products";
|
||||
|
||||
const recommendedProductsEditor: ComponentConfig<RecommendedProductsProps> = {
|
||||
label: "Recommended products",
|
||||
icon: <Sparkles size={16} />,
|
||||
category: "commerce",
|
||||
defaultProps: {
|
||||
product: null,
|
||||
tagline: "You may also like",
|
||||
heading: "More to explore",
|
||||
limit: 4,
|
||||
},
|
||||
fields: {
|
||||
product: { label: "Source product", type: "shopifyProduct" } as any,
|
||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
||||
limit: { label: "Limit", type: "number", min: 2, max: 8 },
|
||||
},
|
||||
render: (props) => <RecommendedProductsView {...props} />,
|
||||
};
|
||||
|
||||
export default recommendedProductsEditor;
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { ShopifyProduct } from "@reacteditor/field-shopify";
|
||||
import {
|
||||
useProduct,
|
||||
useProductRecommendations,
|
||||
} from "@/hooks/use-shopify-products";
|
||||
import { ProductCard } from "./product-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Container } from "@/components/layout/Container";
|
||||
import { Heading } from "@/components/Heading";
|
||||
|
||||
export type RecommendedProductsProps = {
|
||||
product: ShopifyProduct | null;
|
||||
tagline: string;
|
||||
heading: string;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export function RecommendedProductsView({
|
||||
product: selected,
|
||||
tagline,
|
||||
heading,
|
||||
limit,
|
||||
}: RecommendedProductsProps) {
|
||||
const { product } = useProduct(selected?.handle ?? null);
|
||||
const { recommendations } = useProductRecommendations(product?.id ?? null);
|
||||
const items = (recommendations ?? []).slice(0, limit);
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<div className="mb-12 flex max-w-xl flex-col gap-3">
|
||||
{tagline ? <Skeleton className="h-3 w-24" /> : null}
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
|
||||
{Array.from({ length: limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-background py-20 md:py-28">
|
||||
<Container>
|
||||
<Heading
|
||||
tagline={tagline}
|
||||
title={heading}
|
||||
align="left"
|
||||
size="md"
|
||||
className="mb-12"
|
||||
maxWidth="max-w-xl"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-12 md:grid-cols-4">
|
||||
{items.length === 0
|
||||
? Array.from({ length: limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: items.map((p: any) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from '@/components/ui/command';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { RiSearchLine, RiImageLine, RiCloseLine } from '@remixicon/react';
|
||||
import {
|
||||
searchSuggestions,
|
||||
type SearchSuggestion,
|
||||
} from '@/hooks/use-shopify-search';
|
||||
|
||||
const DEBOUNCE_MS = 250;
|
||||
const SUGGESTION_COUNT = 3;
|
||||
|
||||
const formatPrice = (amount: string) => `$${parseFloat(amount).toFixed(2)}`;
|
||||
|
||||
const SearchDialog: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [term, setTerm] = useState('');
|
||||
const [results, setResults] = useState<SearchSuggestion[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
// Guards against a slow early request overwriting a newer one's results.
|
||||
const requestId = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const query = term.trim();
|
||||
|
||||
if (!query) {
|
||||
setResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
const id = ++requestId.current;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const { products } = await searchSuggestions(query, SUGGESTION_COUNT);
|
||||
if (id !== requestId.current) return;
|
||||
setResults(products);
|
||||
} catch (err) {
|
||||
console.error('Search failed:', err);
|
||||
if (id === requestId.current) setResults([]);
|
||||
} finally {
|
||||
if (id === requestId.current) setSearching(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [term]);
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
setTerm('');
|
||||
setResults([]);
|
||||
};
|
||||
|
||||
const goToSearchPage = () => {
|
||||
const query = term.trim();
|
||||
close();
|
||||
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search');
|
||||
};
|
||||
|
||||
const goToProduct = (handle: string) => {
|
||||
close();
|
||||
router.push(`/products/${handle}`);
|
||||
};
|
||||
|
||||
const hasQuery = Boolean(term.trim());
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Search"
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiSearchLine className="size-5" />
|
||||
</Button>
|
||||
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={(next) => !next && close()}
|
||||
title="Search"
|
||||
description="Search products"
|
||||
showCloseButton={false}
|
||||
className="top-24 max-w-xl translate-y-0"
|
||||
// Results come from the Storefront API, so cmdk must not re-filter them.
|
||||
shouldFilter={false}
|
||||
>
|
||||
<div className="relative">
|
||||
<CommandInput
|
||||
value={term}
|
||||
onValueChange={setTerm}
|
||||
placeholder="Search products"
|
||||
className="pr-28"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') goToSearchPage();
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-2 top-0 flex h-12 items-center gap-1">
|
||||
{hasQuery && (
|
||||
<Button
|
||||
onClick={() => setTerm('')}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={close}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close search"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasQuery && (
|
||||
<>
|
||||
<div className="px-3 pt-3">
|
||||
<button
|
||||
onClick={goToSearchPage}
|
||||
className="rounded-full border border-border px-3 py-1 text-sm text-foreground transition-colors hover:border-foreground"
|
||||
>
|
||||
{term.trim()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CommandList className="max-h-80">
|
||||
{searching && results.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader size={20} />
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<CommandEmpty>No products found.</CommandEmpty>
|
||||
) : (
|
||||
<CommandGroup heading="Products">
|
||||
{results.map((product) => (
|
||||
<CommandItem
|
||||
key={product.id}
|
||||
value={product.handle}
|
||||
onSelect={() => goToProduct(product.handle)}
|
||||
className="gap-3 py-2"
|
||||
>
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden bg-zinc-100">
|
||||
{product.featuredImage ? (
|
||||
<Image
|
||||
src={product.featuredImage.url}
|
||||
alt={product.featuredImage.altText || product.title}
|
||||
width={56}
|
||||
height={56}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-400">
|
||||
<RiImageLine className="size-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{product.title}
|
||||
</div>
|
||||
<div className="font-mono text-sm tabular-nums tracking-tight text-foreground">
|
||||
{formatPrice(
|
||||
product.priceRange.minVariantPrice.amount
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="flex justify-center px-4 pb-5 pt-2">
|
||||
<Button onClick={goToSearchPage} className="px-8">
|
||||
View All
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDialog;
|
||||
@@ -1,244 +0,0 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { Search } from 'lucide-react';
|
||||
import { SearchProductsView, type SearchProductsProps } from '@/components/shopify/search-products';
|
||||
|
||||
const searchProductsEditor: ComponentConfig<SearchProductsProps> = {
|
||||
label: 'Search & filter',
|
||||
icon: <Search size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
heading: 'Shop',
|
||||
subheading: 'Browse our full collection.',
|
||||
columns: '4',
|
||||
limit: 24,
|
||||
showAvailability: 'yes',
|
||||
showPriceRange: 'yes',
|
||||
showProductType: 'yes',
|
||||
productTypeOptions: [
|
||||
{ label: 'T-Shirts' },
|
||||
{ label: 'Pants' },
|
||||
{ label: 'Outerwear' },
|
||||
{ label: 'Accessories' },
|
||||
{ label: 'Shoes' },
|
||||
],
|
||||
showVendor: 'yes',
|
||||
vendorOptions: [
|
||||
{ label: 'Maison' },
|
||||
{ label: 'Atelier' },
|
||||
{ label: 'Studio' },
|
||||
],
|
||||
showTags: 'yes',
|
||||
tagOptions: [
|
||||
{ label: 'New' },
|
||||
{ label: 'Sale' },
|
||||
{ label: 'Bestseller' },
|
||||
{ label: 'Limited Edition' },
|
||||
],
|
||||
showColor: 'yes',
|
||||
colorOptions: [
|
||||
{ label: 'Black', color: '#000000' },
|
||||
{ label: 'White', color: '#FFFFFF' },
|
||||
{ label: 'Navy', color: '#1e3a5f' },
|
||||
{ label: 'Red', color: '#c0392b' },
|
||||
],
|
||||
showStyle: 'no',
|
||||
styleOptions: [],
|
||||
showSize: 'yes',
|
||||
sizeOptions: [
|
||||
{ label: 'XS' },
|
||||
{ label: 'S' },
|
||||
{ label: 'M' },
|
||||
{ label: 'L' },
|
||||
{ label: 'XL' },
|
||||
],
|
||||
showMaterial: 'no',
|
||||
materialOptions: [],
|
||||
metafieldFilters: [],
|
||||
defaultSort: 'BEST_SELLING',
|
||||
},
|
||||
fields: {
|
||||
heading: { label: 'Heading', type: 'text', contentEditable: true },
|
||||
subheading: { label: 'Subheading', type: 'textarea', contentEditable: true },
|
||||
columns: {
|
||||
label: 'Columns',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
],
|
||||
},
|
||||
limit: { label: 'Products per page', type: 'number', min: 4, max: 48 },
|
||||
defaultSort: {
|
||||
label: 'Default sort',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Best Selling', value: 'BEST_SELLING' },
|
||||
{ label: 'Relevance', value: 'RELEVANCE' },
|
||||
{ label: 'Newest', value: 'NEWEST' },
|
||||
{ label: 'Price: Low to High', value: 'PRICE_ASC' },
|
||||
{ label: 'Price: High to Low', value: 'PRICE_DESC' },
|
||||
{ label: 'Alphabetical', value: 'TITLE_ASC' },
|
||||
],
|
||||
},
|
||||
showAvailability: {
|
||||
label: 'Availability filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
showPriceRange: {
|
||||
label: 'Price range filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
showProductType: {
|
||||
label: 'Product type filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
productTypeOptions: {
|
||||
label: 'Product types',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Type',
|
||||
arrayFields: {
|
||||
label: { label: 'Type name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showVendor: {
|
||||
label: 'Brand / vendor filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
vendorOptions: {
|
||||
label: 'Brands',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Brand',
|
||||
arrayFields: {
|
||||
label: { label: 'Brand name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showTags: {
|
||||
label: 'Tags filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
tagOptions: {
|
||||
label: 'Tags',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Tag',
|
||||
arrayFields: {
|
||||
label: { label: 'Tag name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showColor: {
|
||||
label: 'Color filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
colorOptions: {
|
||||
label: 'Colors',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '', color: '#000000' },
|
||||
getItemSummary: (it: any) => it?.label || 'Color',
|
||||
arrayFields: {
|
||||
label: { label: 'Color name', type: 'text' },
|
||||
color: { label: 'Color', type: 'color' },
|
||||
},
|
||||
},
|
||||
showStyle: {
|
||||
label: 'Style filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
styleOptions: {
|
||||
label: 'Styles',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Style',
|
||||
arrayFields: {
|
||||
label: { label: 'Style name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showSize: {
|
||||
label: 'Size filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
sizeOptions: {
|
||||
label: 'Sizes',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Size',
|
||||
arrayFields: {
|
||||
label: { label: 'Size name', type: 'text' },
|
||||
},
|
||||
},
|
||||
showMaterial: {
|
||||
label: 'Material filter',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{ label: 'Show', value: 'yes' },
|
||||
{ label: 'Hide', value: 'no' },
|
||||
],
|
||||
},
|
||||
materialOptions: {
|
||||
label: 'Materials',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (it: any) => it?.label || 'Material',
|
||||
arrayFields: {
|
||||
label: { label: 'Material name', type: 'text' },
|
||||
},
|
||||
},
|
||||
metafieldFilters: {
|
||||
label: 'Metafield filters',
|
||||
type: 'array',
|
||||
defaultItemProps: { namespace: '', key: '', label: '', values: [{ label: '' }] },
|
||||
getItemSummary: (it: any) => it?.label || it?.key || 'Metafield',
|
||||
arrayFields: {
|
||||
namespace: { label: 'Namespace', type: 'text' },
|
||||
key: { label: 'Key', type: 'text' },
|
||||
label: { label: 'Label', type: 'text' },
|
||||
values: {
|
||||
label: 'Values',
|
||||
type: 'array',
|
||||
defaultItemProps: { label: '' },
|
||||
getItemSummary: (v: any) => v?.label || 'Value',
|
||||
arrayFields: {
|
||||
label: { label: 'Value', type: 'text' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (props) => <SearchProductsView {...props} />,
|
||||
};
|
||||
|
||||
export default searchProductsEditor;
|
||||
@@ -1,533 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
|
||||
import { ChevronDown, SlidersHorizontal } from 'lucide-react';
|
||||
import { useShopifySearch, type SearchFilters, type SortOption } from '@/hooks/use-shopify-search';
|
||||
|
||||
import { ProductCard } from './product-card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, SheetFooter } from '@/components/ui/sheet';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type FilterOption = { label: string };
|
||||
type ColorOption = { label: string; color: string };
|
||||
|
||||
export type SearchProductsProps = {
|
||||
heading: string;
|
||||
subheading: string;
|
||||
columns: '2' | '3' | '4';
|
||||
limit: number;
|
||||
showAvailability: 'yes' | 'no';
|
||||
showPriceRange: 'yes' | 'no';
|
||||
showProductType: 'yes' | 'no';
|
||||
productTypeOptions: FilterOption[];
|
||||
showVendor: 'yes' | 'no';
|
||||
vendorOptions: FilterOption[];
|
||||
showTags: 'yes' | 'no';
|
||||
tagOptions: FilterOption[];
|
||||
showColor: 'yes' | 'no';
|
||||
colorOptions: ColorOption[];
|
||||
showStyle: 'yes' | 'no';
|
||||
styleOptions: FilterOption[];
|
||||
showSize: 'yes' | 'no';
|
||||
sizeOptions: FilterOption[];
|
||||
showMaterial: 'yes' | 'no';
|
||||
materialOptions: FilterOption[];
|
||||
metafieldFilters: { namespace: string; key: string; label: string; values: { label: string }[] }[];
|
||||
defaultSort: SortOption;
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { label: string; value: SortOption }[] = [
|
||||
{ label: 'Relevance', value: 'RELEVANCE' },
|
||||
{ label: 'Best Selling', value: 'BEST_SELLING' },
|
||||
{ label: 'Newest', value: 'NEWEST' },
|
||||
{ label: 'Price: Low to High', value: 'PRICE_ASC' },
|
||||
{ label: 'Price: High to Low', value: 'PRICE_DESC' },
|
||||
{ label: 'Alphabetical', value: 'TITLE_ASC' },
|
||||
];
|
||||
|
||||
const colClass: Record<SearchProductsProps['columns'], string> = {
|
||||
'2': 'grid-cols-2',
|
||||
'3': 'grid-cols-2 md:grid-cols-3',
|
||||
'4': 'grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
// ─── Filter group (collapsible) ────────────────────────────────────────────────
|
||||
|
||||
function FilterGroup({ label, children, defaultOpen = true }: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<div className="border-b border-border py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between text-xs font-semibold uppercase tracking-[0.15em] text-foreground"
|
||||
>
|
||||
{label}
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn('transition-transform', open ? 'rotate-180' : '')}
|
||||
/>
|
||||
</button>
|
||||
{open && <div className="mt-3 space-y-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Checkbox({ checked, onChange, label }: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border',
|
||||
checked ? 'border-foreground bg-foreground' : 'border-border',
|
||||
)}
|
||||
>
|
||||
{checked && (
|
||||
<svg viewBox="0 0 10 8" className="h-2.5 w-2.5 fill-background" aria-hidden>
|
||||
<path d="M1 4l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sidebar ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ActiveFilters = {
|
||||
availability: boolean;
|
||||
productTypes: string[];
|
||||
vendors: string[];
|
||||
tags: string[];
|
||||
colors: string[];
|
||||
styles: string[];
|
||||
sizes: string[];
|
||||
materials: string[];
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
metafieldValues: Record<string, string[]>;
|
||||
};
|
||||
|
||||
function Sidebar({
|
||||
props,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
props: SearchProductsProps;
|
||||
active: ActiveFilters;
|
||||
onChange: (patch: Partial<ActiveFilters>) => void;
|
||||
}) {
|
||||
const productTypes = (props.productTypeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const vendors = (props.vendorOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const tags = (props.tagOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const colors = (props.colorOptions ?? []) as ColorOption[];
|
||||
const styles = (props.styleOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const sizes = (props.sizeOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const materials = (props.materialOptions ?? []).map((o) => o.label).filter(Boolean);
|
||||
const metafieldFilters = (props.metafieldFilters ?? []).filter((mf) => mf.namespace && mf.key);
|
||||
|
||||
function toggle(key: 'productTypes' | 'vendors' | 'tags' | 'colors' | 'styles' | 'sizes' | 'materials', value: string) {
|
||||
const arr = active[key];
|
||||
onChange({ [key]: arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{props.showAvailability === 'yes' && (
|
||||
<FilterGroup label="Availability">
|
||||
<Checkbox
|
||||
checked={active.availability}
|
||||
onChange={(v) => onChange({ availability: v })}
|
||||
label="In stock"
|
||||
/>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showPriceRange === 'yes' && (
|
||||
<FilterGroup label="Price">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Min"
|
||||
value={active.minPrice}
|
||||
onChange={(e) => onChange({ minPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground">–</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="Max"
|
||||
value={active.maxPrice}
|
||||
onChange={(e) => onChange({ maxPrice: e.target.value })}
|
||||
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showVendor === 'yes' && vendors.length > 0 && (
|
||||
<FilterGroup label="Brand">
|
||||
{vendors.map((v) => (
|
||||
<Checkbox
|
||||
key={v}
|
||||
checked={active.vendors.includes(v)}
|
||||
onChange={() => toggle('vendors', v)}
|
||||
label={v}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showColor === 'yes' && colors.length > 0 && (
|
||||
<FilterGroup label="Color">
|
||||
{colors.filter((c) => c.label).map((c) => (
|
||||
<label
|
||||
key={c.label}
|
||||
className="flex cursor-pointer items-center gap-2.5 text-sm text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active.colors.includes(c.label)}
|
||||
onChange={() => toggle('colors', c.label)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 rounded-full border-2',
|
||||
active.colors.includes(c.label) ? 'border-foreground' : 'border-transparent',
|
||||
)}
|
||||
style={{ backgroundColor: c.color || undefined }}
|
||||
/>
|
||||
{c.label}
|
||||
</label>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showStyle === 'yes' && styles.length > 0 && (
|
||||
<FilterGroup label="Style">
|
||||
{styles.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.styles.includes(s)}
|
||||
onChange={() => toggle('styles', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showSize === 'yes' && sizes.length > 0 && (
|
||||
<FilterGroup label="Size">
|
||||
{sizes.map((s) => (
|
||||
<Checkbox
|
||||
key={s}
|
||||
checked={active.sizes.includes(s)}
|
||||
onChange={() => toggle('sizes', s)}
|
||||
label={s}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showMaterial === 'yes' && materials.length > 0 && (
|
||||
<FilterGroup label="Material">
|
||||
{materials.map((m) => (
|
||||
<Checkbox
|
||||
key={m}
|
||||
checked={active.materials.includes(m)}
|
||||
onChange={() => toggle('materials', m)}
|
||||
label={m}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showProductType === 'yes' && productTypes.length > 0 && (
|
||||
<FilterGroup label="Product type">
|
||||
{productTypes.map((pt) => (
|
||||
<Checkbox
|
||||
key={pt}
|
||||
checked={active.productTypes.includes(pt)}
|
||||
onChange={() => toggle('productTypes', pt)}
|
||||
label={pt}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{props.showTags === 'yes' && tags.length > 0 && (
|
||||
<FilterGroup label="Tags">
|
||||
{tags.map((t) => (
|
||||
<Checkbox
|
||||
key={t}
|
||||
checked={active.tags.includes(t)}
|
||||
onChange={() => toggle('tags', t)}
|
||||
label={t}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
)}
|
||||
|
||||
{metafieldFilters.map((mf, i) => {
|
||||
const mfKey = `${mf.namespace}.${mf.key}`;
|
||||
const selected = active.metafieldValues[mfKey] ?? [];
|
||||
return (
|
||||
<FilterGroup key={mfKey + i} label={mf.label || mfKey}>
|
||||
{mf.values.map((v) => v.label).filter(Boolean).map((val) => (
|
||||
<Checkbox
|
||||
key={val}
|
||||
checked={selected.includes(val)}
|
||||
onChange={(checked) => {
|
||||
const next = checked
|
||||
? [...selected, val]
|
||||
: selected.filter((v) => v !== val);
|
||||
onChange({ metafieldValues: { ...active.metafieldValues, [mfKey]: next } });
|
||||
}}
|
||||
label={val}
|
||||
/>
|
||||
))}
|
||||
</FilterGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function SearchProductsView(props: SearchProductsProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const initialQ = searchParams.get('q') ?? '';
|
||||
|
||||
const [query, setQuery] = useState(initialQ);
|
||||
const [inputValue, setInputValue] = useState(initialQ);
|
||||
const [sort, setSort] = useState<SortOption>(props.defaultSort);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [active, setActive] = useState<ActiveFilters>({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
|
||||
const patchActive = useCallback((patch: Partial<ActiveFilters>) => {
|
||||
setActive((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
|
||||
const clearAll = useCallback(() => {
|
||||
setActive({
|
||||
availability: false,
|
||||
productTypes: [],
|
||||
vendors: [],
|
||||
tags: [],
|
||||
colors: [],
|
||||
styles: [],
|
||||
sizes: [],
|
||||
materials: [],
|
||||
minPrice: '',
|
||||
maxPrice: '',
|
||||
metafieldValues: {},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const filters: SearchFilters = {
|
||||
q: query,
|
||||
sort,
|
||||
availability: active.availability || undefined,
|
||||
productTypes: active.productTypes.length ? active.productTypes : undefined,
|
||||
vendors: active.vendors.length ? active.vendors : undefined,
|
||||
tags: active.tags.length ? active.tags : undefined,
|
||||
colors: active.colors.length ? active.colors : undefined,
|
||||
styles: active.styles.length ? active.styles : undefined,
|
||||
sizes: active.sizes.length ? active.sizes : undefined,
|
||||
materials: active.materials.length ? active.materials : undefined,
|
||||
minPrice: active.minPrice !== '' ? parseFloat(active.minPrice) : undefined,
|
||||
maxPrice: active.maxPrice !== '' ? parseFloat(active.maxPrice) : undefined,
|
||||
metafields: (() => {
|
||||
const mfs = Object.entries(active.metafieldValues).flatMap(([mfKey, vals]) => {
|
||||
const [namespace, key] = mfKey.split('.');
|
||||
return vals.map((value) => ({ namespace, key, value }));
|
||||
});
|
||||
return mfs.length ? mfs : undefined;
|
||||
})(),
|
||||
};
|
||||
|
||||
const { products, loading, error, hasNextPage, fetchMore } = useShopifySearch(filters, {
|
||||
first: props.limit,
|
||||
});
|
||||
|
||||
// Sync ?q= param when query changes
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (query) params.set('q', query); else params.delete('q');
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
|
||||
}, [query]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setQuery(inputValue.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-background py-12 md:py-16">
|
||||
<Container>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-10">
|
||||
{props.heading && (
|
||||
<h1 className="mb-2 font-heading text-4xl font-bold tracking-tight text-foreground md:text-5xl">
|
||||
{props.heading}
|
||||
</h1>
|
||||
)}
|
||||
{props.subheading && (
|
||||
<p className="text-muted-foreground">{props.subheading}</p>
|
||||
)}
|
||||
{/* Search bar (mobile only – desktop version lives in the product area) */}
|
||||
<form onSubmit={handleSearch} className="mt-6 flex gap-2 md:hidden">
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Search bar (desktop only — mobile lives in header above) */}
|
||||
<form onSubmit={handleSearch} className="mb-4 hidden gap-2 md:flex">
|
||||
<input
|
||||
type="search"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Search products…"
|
||||
className="flex-1 rounded-md border border-border bg-background px-4 py-2.5 text-sm outline-none focus:border-foreground"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-foreground px-5 py-2.5 text-sm font-medium text-background hover:opacity-90"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Filter + sort bar */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Sheet open={filtersOpen} onOpenChange={setFiltersOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<SlidersHorizontal size={14} />
|
||||
Filters
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filters</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto px-4">
|
||||
<Sidebar props={props} active={active} onChange={patchActive} />
|
||||
</div>
|
||||
<SheetFooter className="flex-row gap-2 border-t border-border">
|
||||
<Button variant="outline" className="flex-1" onClick={clearAll}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button className="flex-1" onClick={() => setFiltersOpen(false)}>
|
||||
Search
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<p className="hidden text-sm text-muted-foreground sm:block">
|
||||
{loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`}
|
||||
</p>
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
|
||||
<SelectTrigger className="h-auto px-3 py-2 text-sm">
|
||||
<SelectValue>{SORT_OPTIONS.find((o) => o.value === sort)?.label}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-border p-4 text-sm text-muted-foreground">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className={cn('grid gap-x-6 gap-y-10', colClass[props.columns])}>
|
||||
{loading
|
||||
? Array.from({ length: props.limit }).map((_, i) => (
|
||||
<Skeleton key={i} className="aspect-[4/5] w-full" />
|
||||
))
|
||||
: products.map((p) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
|
||||
{!loading && products.length === 0 && !error && (
|
||||
<div className="mt-16 text-center text-sm text-muted-foreground">
|
||||
No products found.{query ? ` Try a different search term.` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasNextPage && !loading && (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchMore}
|
||||
className="rounded-md border border-border px-8 py-3 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { Search } from 'lucide-react';
|
||||
import SearchResults from '@/components/shopify/search-results';
|
||||
|
||||
export type SearchResultsBlockProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result set comes from the `?q=` param and the shopper's own filter and
|
||||
* sort choices, so the heading is the only thing left for the editor to own.
|
||||
*/
|
||||
const searchResultsEditor: ComponentConfig<SearchResultsBlockProps> = {
|
||||
label: 'Search results',
|
||||
icon: <Search size={16} />,
|
||||
category: 'commerce',
|
||||
defaultProps: {
|
||||
title: 'Search',
|
||||
},
|
||||
fields: {
|
||||
title: { label: 'Title', type: 'text', contentEditable: true },
|
||||
},
|
||||
render: (props) => <SearchResults {...props} />,
|
||||
};
|
||||
|
||||
export default searchResultsEditor;
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import ProductCard from './product-card';
|
||||
import ProductFilters from './product-filters';
|
||||
import ProductToolbar from './product-toolbar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import {
|
||||
searchProducts,
|
||||
type SearchFilter,
|
||||
type SearchSortKey,
|
||||
} from '@/hooks/use-shopify-search';
|
||||
import type { Product } from '@/hooks/use-shopify-products';
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
interface SortOption {
|
||||
label: string;
|
||||
sortKey: SearchSortKey;
|
||||
reverse: boolean;
|
||||
}
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = [
|
||||
{ label: 'Best Matches', sortKey: 'RELEVANCE', reverse: false },
|
||||
{ label: 'Price: Low to High', sortKey: 'PRICE', reverse: false },
|
||||
{ label: 'Price: High to Low', sortKey: 'PRICE', reverse: true },
|
||||
];
|
||||
|
||||
interface SearchResultsProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const SearchResults: React.FC<SearchResultsProps> = ({ title = 'Search' }) => {
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get('q') ?? '';
|
||||
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [filters, setFilters] = useState<SearchFilter[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasNextPage, setHasNextPage] = useState(false);
|
||||
|
||||
const [sortIndex, setSortIndex] = useState(0);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([]);
|
||||
|
||||
const sort = SORT_OPTIONS[sortIndex];
|
||||
// Serialised so the effect re-runs when the selection changes, not the array.
|
||||
const activeKey = useMemo(() => activeFilters.join('|'), [activeFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await searchProducts({
|
||||
// An empty term still returns the catalogue, which is what an
|
||||
// unqualified /search visit should show.
|
||||
query,
|
||||
first: PAGE_SIZE,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setProducts(result.products);
|
||||
setTotalCount(result.totalCount);
|
||||
setCursor(result.endCursor);
|
||||
setHasNextPage(result.hasNextPage);
|
||||
// Facet counts change with the result set, but keep the panel stable
|
||||
// while filters are applied so options don't vanish mid-selection.
|
||||
if (activeFilters.length === 0) setFilters(result.filters);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error('Search failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Search failed');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [query, sort.sortKey, sort.reverse, activeKey]);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
if (loadingMore || !hasNextPage) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const result = await searchProducts({
|
||||
query,
|
||||
first: PAGE_SIZE,
|
||||
after: cursor,
|
||||
sortKey: sort.sortKey,
|
||||
reverse: sort.reverse,
|
||||
filterInputs: activeFilters,
|
||||
});
|
||||
|
||||
setProducts((prev) => {
|
||||
const seen = new Set(prev.map((p) => p.id));
|
||||
return [...prev, ...result.products.filter((p) => !seen.has(p.id))];
|
||||
});
|
||||
setCursor(result.endCursor);
|
||||
setHasNextPage(result.hasNextPage);
|
||||
} catch (err) {
|
||||
console.error('Failed to load more results:', err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-background py-10">
|
||||
<div className="max-w-screen-2xl mx-auto px-8">
|
||||
<h1 className="text-3xl md:text-4xl font-normal text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-6">
|
||||
<ProductToolbar
|
||||
totalCount={totalCount}
|
||||
onOpenFilters={() => setFiltersOpen(true)}
|
||||
sortOptions={SORT_OPTIONS}
|
||||
sortIndex={sortIndex}
|
||||
onSortChange={setSortIndex}
|
||||
activeFilterCount={activeFilters.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mt-8">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
|
||||
{Array.from({ length: 10 }).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 w-4/5 bg-zinc-200"></div>
|
||||
<div className="h-4 w-1/4 bg-zinc-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No products matched{query ? ` “${query}”` : ' your filters'}.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-x-8 gap-y-12">
|
||||
{products.map((product) => (
|
||||
<ProductCard key={product.id} product={product} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasNextPage && (
|
||||
<div className="mt-16 flex justify-center">
|
||||
<Button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="font-normal text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{loadingMore && <Loader size={16} />}
|
||||
{loadingMore ? 'Loading' : 'Load more'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductFilters
|
||||
open={filtersOpen}
|
||||
onOpenChange={setFiltersOpen}
|
||||
filters={filters}
|
||||
activeFilters={activeFilters}
|
||||
onActiveFiltersChange={setActiveFilters}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResults;
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { RiArrowDownSLine } from '@remixicon/react';
|
||||
import { useCollectionsOnDemand } from '@/hooks/use-shopify-collections';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ShopMenuProps {
|
||||
label?: string;
|
||||
/** Renders inline inside the mobile menu instead of as a floating panel. */
|
||||
mobile?: boolean;
|
||||
/** Fires after a collection is picked, so the mobile menu can close itself. */
|
||||
onNavigate?: () => void;
|
||||
}
|
||||
|
||||
const ShopMenu: React.FC<ShopMenuProps> = ({
|
||||
label = 'Shop',
|
||||
mobile = false,
|
||||
onNavigate,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { collections, loading, error, load } = useCollectionsOnDemand();
|
||||
|
||||
// The collection list is only worth fetching once someone opens the menu.
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (next) load();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
onNavigate?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
const itemClasses = cn(
|
||||
'block text-sm text-foreground hover:bg-accent transition-colors',
|
||||
mobile ? 'px-3 py-2' : 'px-4 py-2'
|
||||
);
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{loading &&
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<div key={index} className={cn(itemClasses, 'py-2.5')}>
|
||||
<div className="h-3 w-2/3 animate-pulse bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div className={cn(itemClasses, 'hover:bg-transparent')}>
|
||||
<p className="text-muted-foreground">{error}</p>
|
||||
<button
|
||||
onClick={load}
|
||||
className="mt-1 underline underline-offset-2 hover:text-muted-foreground"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && collections.length === 0 && (
|
||||
<p className={cn(itemClasses, 'text-muted-foreground hover:bg-transparent')}>
|
||||
No collections yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{collections.map((collection) => (
|
||||
<Link
|
||||
key={collection.id}
|
||||
href={`/collections/${collection.handle}`}
|
||||
onClick={close}
|
||||
className={itemClasses}
|
||||
>
|
||||
{collection.title}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{collections.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 h-px bg-border" />
|
||||
<Link
|
||||
href="/collections"
|
||||
onClick={close}
|
||||
className={cn(itemClasses, 'text-muted-foreground')}
|
||||
>
|
||||
View all collections
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-foreground hover:text-muted-foreground transition-colors',
|
||||
mobile && 'w-full justify-between'
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<RiArrowDownSLine
|
||||
className={cn('size-4 transition-transform', open && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
if (mobile) {
|
||||
return (
|
||||
<div>
|
||||
{trigger}
|
||||
{open && (
|
||||
<div className="mt-2 flex flex-col border-l border-border pl-1">
|
||||
{body}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{trigger}
|
||||
|
||||
{open && (
|
||||
<>
|
||||
{/* Catches the click that dismisses the panel. */}
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute left-0 top-full z-50 mt-2 max-h-[70vh] w-64 overflow-y-auto rounded-md border border-border bg-background py-1 shadow-md">
|
||||
{body}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShopMenu;
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { SHOPIFY_STORE_DOMAIN } from '@/services/shopify/config';
|
||||
import ShopPayLogo from '@/components/shopify/shop-pay-logo';
|
||||
import { Loader } from '@/components/ui/loader';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface ShopPayVariant {
|
||||
id: string;
|
||||
quantity?: number;
|
||||
}
|
||||
|
||||
interface ShopPayButtonProps {
|
||||
variants: ShopPayVariant[];
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
/** Used when no permalink can be built (missing domain or unusable IDs). */
|
||||
onFallbackClick?: () => void;
|
||||
}
|
||||
|
||||
// Cart permalinks need the bare numeric ID; the Storefront API returns GIDs.
|
||||
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 toStoreUrl(domain?: string): string | null {
|
||||
if (!domain) return null;
|
||||
try {
|
||||
return new URL(domain.startsWith('http') ? domain : `https://${domain}`)
|
||||
.origin;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// https://{shop}/cart/{variantId}:{qty},{variantId}:{qty}?payment=shop_pay
|
||||
// Loads the cart and drops the buyer straight into the Shop Pay checkout.
|
||||
export function buildShopPayUrl(variants: ShopPayVariant[]): string | null {
|
||||
const storeUrl = toStoreUrl(SHOPIFY_STORE_DOMAIN);
|
||||
if (!storeUrl || variants.length === 0) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const { id, quantity = 1 } of variants) {
|
||||
const numericId = toNumericVariantId(id);
|
||||
if (!numericId || !Number.isInteger(quantity) || quantity < 1) return null;
|
||||
lines.push(`${numericId}:${quantity}`);
|
||||
}
|
||||
|
||||
return `${storeUrl}/cart/${lines.join(',')}?payment=shop_pay`;
|
||||
}
|
||||
|
||||
const BUTTON_CLASSES =
|
||||
'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';
|
||||
|
||||
const ShopPayButton: React.FC<ShopPayButtonProps> = ({
|
||||
variants,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
onFallbackClick,
|
||||
}) => {
|
||||
const shopPayUrl = buildShopPayUrl(variants);
|
||||
const contents = (
|
||||
<>
|
||||
<span className="sr-only">Buy with</span>
|
||||
{loading ? <Loader size={16} /> : <ShopPayLogo />}
|
||||
</>
|
||||
);
|
||||
|
||||
// An anchor keeps the checkout URL visible, openable in a new tab, and
|
||||
// navigable without JS; the button only stands in when there's no URL.
|
||||
if (shopPayUrl && !disabled) {
|
||||
return (
|
||||
<a
|
||||
href={shopPayUrl}
|
||||
className={`${BUTTON_CLASSES} ${className}`.trim()}
|
||||
>
|
||||
{contents}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onFallbackClick}
|
||||
disabled={disabled || (!shopPayUrl && !onFallbackClick)}
|
||||
className={`${BUTTON_CLASSES} ${className}`.trim()}
|
||||
>
|
||||
{contents}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
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,21 @@
|
||||
import { ComponentConfig } from '@reacteditor/core';
|
||||
import { MessageCircle } from 'lucide-react';
|
||||
import StoreAssistant from '@/components/shopify/store-assistant';
|
||||
|
||||
export type StoreAssistantBlockProps = Record<string, never>;
|
||||
|
||||
/**
|
||||
* The floating AI shopping assistant. It has no editable copy — its prompts and
|
||||
* suggestions come from `/api/chat` — so it is registered purely so a page can
|
||||
* choose whether to carry it.
|
||||
*/
|
||||
const storeAssistantEditor: ComponentConfig<StoreAssistantBlockProps> = {
|
||||
label: 'Store assistant',
|
||||
icon: <MessageCircle size={16} />,
|
||||
category: 'content',
|
||||
defaultProps: {},
|
||||
fields: {},
|
||||
render: () => <StoreAssistant />,
|
||||
};
|
||||
|
||||
export default storeAssistantEditor;
|
||||
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation';
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputActionAddAttachments,
|
||||
PromptInputActionMenu,
|
||||
PromptInputActionMenuContent,
|
||||
PromptInputActionMenuTrigger,
|
||||
PromptInputBody,
|
||||
PromptInputFooter,
|
||||
PromptInputProvider,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
usePromptInputAttachments,
|
||||
type PromptInputMessage,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentHoverCard,
|
||||
AttachmentHoverCardContent,
|
||||
AttachmentHoverCardTrigger,
|
||||
AttachmentInfo,
|
||||
AttachmentPreview,
|
||||
AttachmentRemove,
|
||||
Attachments,
|
||||
getAttachmentLabel,
|
||||
getMediaCategory,
|
||||
type AttachmentData,
|
||||
} from '@/components/ai-elements/attachments';
|
||||
import {
|
||||
Suggestions,
|
||||
Suggestion,
|
||||
} from '@/components/ai-elements/suggestion';
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning';
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RainbowButton } from '@/components/ui/rainbow-button';
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiSearchLine,
|
||||
RiPriceTag3Line,
|
||||
RiStore2Line,
|
||||
RiLayoutGridLine,
|
||||
RiShoppingBag3Line,
|
||||
} from '@remixicon/react';
|
||||
|
||||
// Feature flag. Written as a static member expression so Next inlines it at
|
||||
// build time; the assistant is off unless the env var is explicitly "1".
|
||||
const AI_ENABLED = process.env.NEXT_PUBLIC_ENABLE_AI === '1';
|
||||
|
||||
const SUGGESTIONS = [
|
||||
'What do you sell?',
|
||||
'Show me hoodies under $100',
|
||||
'What collections are there?',
|
||||
];
|
||||
|
||||
interface ToolProduct {
|
||||
handle: string;
|
||||
title: string;
|
||||
image: string | null;
|
||||
price?: string;
|
||||
}
|
||||
|
||||
interface ToolSummary {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
products: ToolProduct[];
|
||||
}
|
||||
|
||||
const LOADING_LABELS: Record<string, string> = {
|
||||
searchCatalogue: 'Searching the catalogue',
|
||||
getProductDetails: 'Reading product details',
|
||||
listCollections: 'Listing collections',
|
||||
getCollectionProducts: 'Browsing a collection',
|
||||
browseProducts: 'Browsing new arrivals',
|
||||
};
|
||||
|
||||
const toolName = (type: string) =>
|
||||
type.startsWith('tool-') ? type.slice(5) : type;
|
||||
|
||||
const plural = (count: number, noun: string) =>
|
||||
`${count} ${noun}${count === 1 ? '' : 's'}`;
|
||||
|
||||
// Turns a finished tool result into the one-line summary plus any products
|
||||
// worth previewing.
|
||||
const summariseTool = (
|
||||
name: string,
|
||||
output: Record<string, unknown> | undefined
|
||||
): ToolSummary => {
|
||||
const products = (output?.products as ToolProduct[] | undefined) ?? [];
|
||||
|
||||
switch (name) {
|
||||
case 'searchCatalogue': {
|
||||
const total = (output?.totalCount as number) ?? products.length;
|
||||
return {
|
||||
label: `Found ${plural(total, 'product')}`,
|
||||
icon: <RiSearchLine className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
}
|
||||
case 'getProductDetails':
|
||||
return {
|
||||
label: output?.found
|
||||
? `Read ${output.title as string}`
|
||||
: 'Product not found',
|
||||
icon: <RiPriceTag3Line className="size-3.5" />,
|
||||
products: output?.found
|
||||
? [
|
||||
{
|
||||
handle: output.handle as string,
|
||||
title: output.title as string,
|
||||
image: (output.image as string) ?? null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
case 'listCollections': {
|
||||
const collections =
|
||||
(output?.collections as Array<unknown> | undefined) ?? [];
|
||||
return {
|
||||
label: `Found ${plural(collections.length, 'collection')}`,
|
||||
icon: <RiStore2Line className="size-3.5" />,
|
||||
products: [],
|
||||
};
|
||||
}
|
||||
case 'getCollectionProducts':
|
||||
return {
|
||||
label: output?.found
|
||||
? `Found ${plural(products.length, 'product')} in ${output.collection}`
|
||||
: 'Collection not found',
|
||||
icon: <RiLayoutGridLine className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
case 'browseProducts':
|
||||
return {
|
||||
label: `Browsed ${plural(products.length, 'new arrival')}`,
|
||||
icon: <RiShoppingBag3Line className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: name,
|
||||
icon: <RiSearchLine className="size-3.5" />,
|
||||
products,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Storefront links stay in-app, so they skip Streamdown's external-link modal.
|
||||
const isInternalLink = (url: string) => {
|
||||
if (url.startsWith('/')) return true;
|
||||
try {
|
||||
return new URL(url, window.location.origin).origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const ProductPreviews: React.FC<{ products: ToolProduct[] }> = ({
|
||||
products,
|
||||
}) => {
|
||||
const withImages = products.filter((product) => product.image);
|
||||
if (withImages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Attachments variant="grid" className="ml-0 mt-2">
|
||||
{withImages.slice(0, 6).map((product) => (
|
||||
<Link
|
||||
key={product.handle}
|
||||
href={`/products/${product.handle}`}
|
||||
title={product.title}
|
||||
className="group/product w-20"
|
||||
>
|
||||
<Attachment
|
||||
data={{
|
||||
id: product.handle,
|
||||
type: 'file',
|
||||
url: product.image as string,
|
||||
mediaType: 'image/jpeg',
|
||||
filename: product.title,
|
||||
}}
|
||||
className="size-20"
|
||||
>
|
||||
<AttachmentPreview />
|
||||
</Attachment>
|
||||
<span className="mt-1 line-clamp-2 block text-[11px] leading-tight text-muted-foreground group-hover/product:text-foreground">
|
||||
{product.title}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</Attachments>
|
||||
);
|
||||
};
|
||||
|
||||
interface AttachmentItemProps {
|
||||
attachment: AttachmentData;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const AttachmentItem = memo(({ attachment, onRemove }: AttachmentItemProps) => {
|
||||
const handleRemove = useCallback(
|
||||
() => onRemove(attachment.id),
|
||||
[onRemove, attachment.id]
|
||||
);
|
||||
const mediaCategory = getMediaCategory(attachment);
|
||||
const label = getAttachmentLabel(attachment);
|
||||
|
||||
return (
|
||||
<AttachmentHoverCard key={attachment.id}>
|
||||
<AttachmentHoverCardTrigger asChild>
|
||||
<Attachment data={attachment} onRemove={handleRemove}>
|
||||
{/* Thumbnail swaps to the remove button on hover. */}
|
||||
<div className="group relative size-5 shrink-0">
|
||||
<div className="absolute inset-0 transition-opacity group-hover:opacity-0">
|
||||
<AttachmentPreview />
|
||||
</div>
|
||||
<AttachmentRemove className="absolute inset-0" />
|
||||
</div>
|
||||
<AttachmentInfo />
|
||||
</Attachment>
|
||||
</AttachmentHoverCardTrigger>
|
||||
<AttachmentHoverCardContent>
|
||||
<div className="space-y-2">
|
||||
{mediaCategory === 'image' &&
|
||||
attachment.type === 'file' &&
|
||||
attachment.url && (
|
||||
<div className="flex max-h-96 w-80 items-center justify-center overflow-hidden rounded-md border">
|
||||
<img
|
||||
alt={label}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
height={384}
|
||||
src={attachment.url}
|
||||
width={320}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 px-0.5">
|
||||
<h4 className="text-sm font-semibold leading-none">{label}</h4>
|
||||
{attachment.mediaType && (
|
||||
<p className="font-mono text-xs text-muted-foreground">
|
||||
{attachment.mediaType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AttachmentHoverCardContent>
|
||||
</AttachmentHoverCard>
|
||||
);
|
||||
});
|
||||
|
||||
AttachmentItem.displayName = 'AttachmentItem';
|
||||
|
||||
// Pending uploads, shown inline above the textarea.
|
||||
const PromptInputAttachmentsDisplay = () => {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => attachments.remove(id),
|
||||
[attachments]
|
||||
);
|
||||
|
||||
if (attachments.files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Attachments variant="inline" className="w-full justify-start px-2 pt-2">
|
||||
{attachments.files.map((attachment) => (
|
||||
<AttachmentItem
|
||||
attachment={attachment}
|
||||
key={attachment.id}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</Attachments>
|
||||
);
|
||||
};
|
||||
|
||||
const StoreAssistant: React.FC = () => {
|
||||
// Returns before any hooks run — safe because the flag is a build-time
|
||||
// constant and cannot change between renders.
|
||||
if (!AI_ENABLED) return null;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
// Drives the launcher's slide-in on first paint.
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const { messages, sendMessage, status, error } = useChat({
|
||||
transport: new DefaultChatTransport({ api: '/api/chat' }),
|
||||
});
|
||||
|
||||
const isBusy = status === 'submitted' || status === 'streaming';
|
||||
|
||||
const launcherClasses = `fixed bottom-6 right-4 z-50 rounded-full px-6 shadow-lg transition-all duration-500 sm:right-6 ${
|
||||
mounted ? 'translate-y-0 opacity-100' : 'translate-y-24 opacity-0'
|
||||
}`;
|
||||
|
||||
const send = (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isBusy) return;
|
||||
sendMessage({ text: trimmed });
|
||||
setInput('');
|
||||
};
|
||||
|
||||
// Attachments arrive on the submitted message, so images go out with it.
|
||||
const handleSubmit = (message: PromptInputMessage) => {
|
||||
const text = (message.text ?? input).trim();
|
||||
const files = message.files ?? [];
|
||||
if ((!text && files.length === 0) || isBusy) return;
|
||||
|
||||
sendMessage({ text, files });
|
||||
setInput('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Popover panel, anchored above the launcher */}
|
||||
<div
|
||||
aria-hidden={!open}
|
||||
className={`fixed bottom-20 right-4 z-50 flex w-[calc(100vw-2rem)] max-w-sm flex-col overflow-hidden rounded-xl border border-border bg-background shadow-xl transition-all duration-200 sm:right-6 ${
|
||||
open
|
||||
? 'pointer-events-auto translate-y-0 opacity-100'
|
||||
: 'pointer-events-none translate-y-2 opacity-0'
|
||||
}`}
|
||||
style={{ height: 'min(32rem, calc(100vh - 8rem))' }}
|
||||
>
|
||||
<header className="flex h-12 shrink-0 items-center justify-between pl-4 pr-2">
|
||||
<span className="text-sm font-medium">Store Assistant</span>
|
||||
<Button
|
||||
onClick={() => setOpen(false)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Close assistant"
|
||||
className="rounded-full"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="gap-4 p-3">
|
||||
{messages.length === 0 && (
|
||||
<ConversationEmptyState
|
||||
title="Ask about the store"
|
||||
description="Find products, compare options, browse collections."
|
||||
>
|
||||
{/* w-full + wrap so chips stack in the narrow popover rather
|
||||
than scrolling off the edge. */}
|
||||
<Suggestions className="mt-3 w-full flex-wrap justify-center">
|
||||
{SUGGESTIONS.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion}
|
||||
onClick={send}
|
||||
suggestion={suggestion}
|
||||
className="font-normal"
|
||||
/>
|
||||
))}
|
||||
</Suggestions>
|
||||
</ConversationEmptyState>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
const fileParts = message.parts.filter(
|
||||
(part) => part.type === 'file'
|
||||
);
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, index) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<MessageResponse
|
||||
key={index}
|
||||
// Only repair half-written markdown while it streams;
|
||||
// settled text is rendered exactly as sent.
|
||||
isAnimating={
|
||||
status === 'streaming' && part.state === 'streaming'
|
||||
}
|
||||
linkSafety={{
|
||||
enabled: true,
|
||||
onLinkCheck: isInternalLink,
|
||||
}}
|
||||
>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type === 'reasoning') {
|
||||
return (
|
||||
<Reasoning
|
||||
key={index}
|
||||
className="w-full"
|
||||
isStreaming={
|
||||
status === 'streaming' &&
|
||||
part.state === 'streaming'
|
||||
}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const toolPart = part as typeof part & {
|
||||
state: string;
|
||||
output?: Record<string, unknown>;
|
||||
errorText?: string;
|
||||
};
|
||||
const name = toolName(part.type);
|
||||
|
||||
// Shimmer while the call is in flight; a quiet summary
|
||||
// line once it returns.
|
||||
if (
|
||||
toolPart.state === 'input-streaming' ||
|
||||
toolPart.state === 'input-available'
|
||||
) {
|
||||
return (
|
||||
<Shimmer key={index} className="text-xs">
|
||||
{LOADING_LABELS[name] ?? name}
|
||||
</Shimmer>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolPart.state === 'output-error') {
|
||||
return (
|
||||
<p key={index} className="text-xs text-muted-foreground">
|
||||
Couldn't load that.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { label, icon, products } = summariseTool(
|
||||
name,
|
||||
toolPart.output
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={index} className="my-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<ProductPreviews products={products} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Images the shopper attached, shown under their message. */}
|
||||
{fileParts.length > 0 && (
|
||||
<Attachments variant="grid" className="ml-0 justify-start">
|
||||
{fileParts.map((part, index) => (
|
||||
<Attachment
|
||||
key={`${message.id}-file-${index}`}
|
||||
data={{
|
||||
id: `${message.id}-file-${index}`,
|
||||
type: 'file',
|
||||
url: part.url,
|
||||
mediaType: part.mediaType,
|
||||
filename: part.filename,
|
||||
}}
|
||||
className="size-20"
|
||||
>
|
||||
<AttachmentPreview />
|
||||
</Attachment>
|
||||
))}
|
||||
</Attachments>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
|
||||
{status === 'submitted' && (
|
||||
<Shimmer className="text-xs">Thinking</Shimmer>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-destructive">
|
||||
Something went wrong. Please try again.
|
||||
</p>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
|
||||
<div className="shrink-0 p-2">
|
||||
<PromptInputProvider>
|
||||
<PromptInput
|
||||
globalDrop
|
||||
multiple
|
||||
accept="image/*"
|
||||
onSubmit={handleSubmit}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<PromptInputAttachmentsDisplay />
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder="Ask about products…"
|
||||
className="min-h-12"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter>
|
||||
<PromptInputTools>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger />
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments />
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit status={status} />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</PromptInputProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Launcher */}
|
||||
{/* Rainbow treatment only while the assistant is open; otherwise the
|
||||
launcher matches the rest of the site's buttons. */}
|
||||
{open ? (
|
||||
<RainbowButton
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close store assistant"
|
||||
aria-expanded
|
||||
size="lg"
|
||||
className={launcherClasses}
|
||||
>
|
||||
Ask
|
||||
</RainbowButton>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open store assistant"
|
||||
aria-expanded={false}
|
||||
className={`h-11 ${launcherClasses}`}
|
||||
>
|
||||
Ask
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StoreAssistant;
|
||||
Reference in New Issue
Block a user