diff --git a/app.schema.json b/app.schema.json index da14dfe..becc145 100644 --- a/app.schema.json +++ b/app.schema.json @@ -12,6 +12,7 @@ "fgColor": "#0a0a0a", "mutedColor": "#f5f5f5", "radius": "sm", + "buttonRadius": "md", "shadow": "sm", "maxWidth": "xl" } @@ -23,10 +24,22 @@ "id": "nav-home", "brand": "Maison", "links": [ - { "label": "Shop", "href": "/collections" }, - { "label": "Lookbook", "href": "/lookbook" }, - { "label": "Journal", "href": "/journal" }, - { "label": "About", "href": "/about" } + { + "label": "Shop", + "href": "/search" + }, + { + "label": "Mens", + "href": "/collections/mens" + }, + { + "label": "Womens", + "href": "/collections/womens" + }, + { + "label": "About", + "href": "/about" + } ], "showSearch": "yes", "showAccount": "yes", @@ -42,8 +55,14 @@ "tagline": "Spring 2026", "heading": "Made for the way you move", "subheading": "A considered wardrobe of essentials, cut from natural fibers and designed to last.", - "primaryCta": { "label": "Shop the collection", "href": "/collections" }, - "secondaryCta": { "label": "Our story", "href": "/about" }, + "primaryCta": { + "label": "Shop the collection", + "href": "/collections" + }, + "secondaryCta": { + "label": "Our story", + "href": "/about" + }, "imageUrl": "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=2400&q=80", "align": "left", "height": "lg", @@ -94,9 +113,18 @@ "subheading": "A small set of values that shape every piece we make.", "columns": "3", "items": [ - { "title": "Natural fibers", "body": "Linen, organic cotton, and merino — sourced from mills with traceable supply chains." }, - { "title": "Small batches", "body": "Made in considered quantities so nothing goes to waste." }, - { "title": "Built to last", "body": "Reinforced seams and finishes that age into something better." } + { + "title": "Natural fibers", + "body": "Linen, organic cotton, and merino — sourced from mills with traceable supply chains." + }, + { + "title": "Small batches", + "body": "Made in considered quantities so nothing goes to waste." + }, + { + "title": "Built to last", + "body": "Reinforced seams and finishes that age into something better." + } ] } }, @@ -145,39 +173,81 @@ { "title": "Shop", "links": [ - { "label": "All", "href": "/collections" }, - { "label": "New", "href": "/collections/new" }, - { "label": "Best sellers", "href": "/collections/best" } + { + "label": "All", + "href": "/search" + }, + { + "label": "New", + "href": "/collections/new" + }, + { + "label": "Best sellers", + "href": "/collections/best" + } ] }, { "title": "About", "links": [ - { "label": "Our story", "href": "/about" }, - { "label": "Materials", "href": "/materials" }, - { "label": "Journal", "href": "/journal" } + { + "label": "Our story", + "href": "/about" + }, + { + "label": "Materials", + "href": "/materials" + }, + { + "label": "Journal", + "href": "/journal" + } ] }, { "title": "Help", "links": [ - { "label": "Shipping", "href": "/help/shipping" }, - { "label": "Returns", "href": "/help/returns" }, - { "label": "Contact", "href": "/contact" } + { + "label": "Shipping", + "href": "/help/shipping" + }, + { + "label": "Returns", + "href": "/help/returns" + }, + { + "label": "Contact", + "href": "/contact" + } ] }, { "title": "Legal", "links": [ - { "label": "Terms", "href": "/terms" }, - { "label": "Privacy", "href": "/privacy" } + { + "label": "Terms", + "href": "/terms" + }, + { + "label": "Privacy", + "href": "/privacy" + } ] } ], "social": [ - { "label": "Instagram", "href": "#" }, - { "label": "Pinterest", "href": "#" }, - { "label": "TikTok", "href": "#" } + { + "label": "Instagram", + "href": "#" + }, + { + "label": "Pinterest", + "href": "#" + }, + { + "label": "TikTok", + "href": "#" + } ], "showNewsletter": "no", "newsletterHeading": "Stay in touch", @@ -187,7 +257,7 @@ } ] }, - "/products/:handle": { + "/products/:handle": { "root": { "props": { "title": "Default product", @@ -200,6 +270,7 @@ "fgColor": "#0a0a0a", "mutedColor": "#f5f5f5", "radius": "sm", + "buttonRadius": "md", "shadow": "sm", "maxWidth": "xl" } @@ -211,10 +282,22 @@ "id": "nav-product", "brand": "Maison", "links": [ - { "label": "Shop", "href": "/collections" }, - { "label": "Lookbook", "href": "/lookbook" }, - { "label": "Journal", "href": "/journal" }, - { "label": "About", "href": "/about" } + { + "label": "Shop", + "href": "/search" + }, + { + "label": "Mens", + "href": "/collections/mens" + }, + { + "label": "Womens", + "href": "/collections/womens" + }, + { + "label": "About", + "href": "/about" + } ], "showSearch": "yes", "showAccount": "yes", @@ -252,21 +335,532 @@ { "title": "Shop", "links": [ - { "label": "All", "href": "/collections" }, - { "label": "New", "href": "/collections/new" } + { + "label": "All", + "href": "/search" + }, + { + "label": "New", + "href": "/collections/new" + } ] }, { "title": "Help", "links": [ - { "label": "Shipping", "href": "/help/shipping" }, - { "label": "Returns", "href": "/help/returns" } + { + "label": "Shipping", + "href": "/help/shipping" + }, + { + "label": "Returns", + "href": "/help/returns" + } ] } ], "social": [ - { "label": "Instagram", "href": "#" }, - { "label": "Pinterest", "href": "#" } + { + "label": "Instagram", + "href": "#" + }, + { + "label": "Pinterest", + "href": "#" + } + ], + "showNewsletter": "no", + "newsletterHeading": "Stay in touch", + "newsletterEndpoint": "", + "copyright": "© 2026 Maison. All rights reserved." + } + } + ] + }, + "/collections/:handle": { + "root": { + "props": { + "title": "Collection", + "headerFont": "Playfair Display", + "bodyFont": "Inter", + "primaryColor": "#0a0a0a", + "secondaryColor": "#64748B", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#0a0a0a", + "mutedColor": "#f5f5f5", + "radius": "sm", + "buttonRadius": "md", + "shadow": "sm", + "maxWidth": "xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-collection", + "brand": "Maison", + "links": [ + { + "label": "Shop", + "href": "/search" + }, + { + "label": "Mens", + "href": "/collections/mens" + }, + { + "label": "Womens", + "href": "/collections/womens" + }, + { + "label": "About", + "href": "/about" + } + ], + "showSearch": "yes", + "showAccount": "yes", + "showCart": "yes", + "sticky": "yes", + "tone": "default" + } + }, + { + "type": "collection", + "props": { + "id": "collection-detail", + "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": [] + } + }, + { + "type": "footer", + "props": { + "id": "footer-collection", + "brand": "Maison", + "tagline": "Considered essentials, made in small batches.", + "columns": [ + { + "title": "Shop", + "links": [ + { + "label": "All", + "href": "/search" + }, + { + "label": "New", + "href": "/collections/new" + } + ] + }, + { + "title": "Help", + "links": [ + { + "label": "Shipping", + "href": "/help/shipping" + }, + { + "label": "Returns", + "href": "/help/returns" + } + ] + } + ], + "social": [ + { + "label": "Instagram", + "href": "#" + }, + { + "label": "Pinterest", + "href": "#" + } + ], + "showNewsletter": "no", + "newsletterHeading": "Stay in touch", + "newsletterEndpoint": "", + "copyright": "© 2026 Maison. All rights reserved." + } + } + ] + }, + "/search": { + "root": { + "props": { + "title": "Shop", + "headerFont": "Playfair Display", + "bodyFont": "Inter", + "primaryColor": "#0a0a0a", + "secondaryColor": "#64748B", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#0a0a0a", + "mutedColor": "#f5f5f5", + "radius": "sm", + "buttonRadius": "md", + "shadow": "sm", + "maxWidth": "xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-search", + "brand": "Maison", + "links": [ + { + "label": "Shop", + "href": "/search" + }, + { + "label": "Mens", + "href": "/collections/mens" + }, + { + "label": "Womens", + "href": "/collections/womens" + }, + { + "label": "About", + "href": "/about" + } + ], + "showSearch": "yes", + "showAccount": "yes", + "showCart": "yes", + "sticky": "yes", + "tone": "default" + } + }, + { + "type": "search-products", + "props": { + "id": "search-products", + "heading": "Shop", + "subheading": "Browse our full collection.", + "columns": "4", + "limit": 24, + "showAvailability": "yes", + "showPriceRange": "yes", + "showProductType": "yes", + "showVendor": "yes", + "showTags": "yes", + "metafieldFilters": [], + "defaultSort": "BEST_SELLING", + "productTypeOptions": [ + { + "label": "T-Shirts" + }, + { + "label": "Pants" + }, + { + "label": "Outerwear" + }, + { + "label": "Accessories" + }, + { + "label": "Shoes" + } + ], + "vendorOptions": [ + { + "label": "Maison" + }, + { + "label": "Atelier" + }, + { + "label": "Studio" + } + ], + "tagOptions": [ + { + "label": "New" + }, + { + "label": "Sale" + }, + { + "label": "Bestseller" + }, + { + "label": "Limited Edition" + } + ] + } + }, + { + "type": "footer", + "props": { + "id": "footer-search", + "brand": "Maison", + "tagline": "Considered essentials, made in small batches.", + "columns": [ + { + "title": "Shop", + "links": [ + { + "label": "All", + "href": "/search" + }, + { + "label": "Mens", + "href": "/collections/mens" + }, + { + "label": "Womens", + "href": "/collections/womens" + } + ] + }, + { + "title": "Help", + "links": [ + { + "label": "Shipping", + "href": "/help/shipping" + }, + { + "label": "Returns", + "href": "/help/returns" + } + ] + } + ], + "social": [ + { + "label": "Instagram", + "href": "#" + }, + { + "label": "Pinterest", + "href": "#" + } + ], + "showNewsletter": "no", + "newsletterHeading": "Stay in touch", + "newsletterEndpoint": "", + "copyright": "© 2026 Maison. All rights reserved." + } + } + ] + }, + "/about": { + "root": { + "props": { + "title": "About — Maison", + "headerFont": "Playfair Display", + "bodyFont": "Inter", + "primaryColor": "#0a0a0a", + "secondaryColor": "#64748B", + "accentColor": "#f5f5f5", + "bgColor": "#ffffff", + "fgColor": "#0a0a0a", + "mutedColor": "#f5f5f5", + "radius": "sm", + "buttonRadius": "md", + "shadow": "sm", + "maxWidth": "xl" + } + }, + "content": [ + { + "type": "navigation", + "props": { + "id": "nav-about", + "brand": "Maison", + "links": [ + { + "label": "Shop", + "href": "/search" + }, + { + "label": "Mens", + "href": "/collections/mens" + }, + { + "label": "Womens", + "href": "/collections/womens" + }, + { + "label": "About", + "href": "/about" + } + ], + "showSearch": "yes", + "showAccount": "yes", + "showCart": "yes", + "sticky": "yes", + "tone": "default" + } + }, + { + "type": "hero", + "props": { + "id": "hero-about", + "tagline": "Our Story", + "heading": "Built on intention, not trend", + "subheading": "We started Maison with a simple belief: clothing should be made slowly, from honest materials, and designed to outlast the season it was born in.", + "primaryCta": { + "label": "Shop the collection", + "href": "/search" + }, + "secondaryCta": { + "label": "", + "href": "" + }, + "imageUrl": "https://images.unsplash.com/photo-1441986300917-64674bd600d8?auto=format&fit=crop&w=2400&q=80", + "align": "left", + "height": "md", + "tone": "dark" + } + }, + { + "type": "features", + "props": { + "id": "features-about", + "tagline": "What we believe", + "heading": "The principles behind every piece", + "subheading": "", + "columns": "3", + "items": [ + { + "title": "Traceable materials", + "body": "Every fiber we use — linen, organic cotton, merino — comes from mills with transparent, auditable supply chains." + }, + { + "title": "Small-batch production", + "body": "We produce in considered quantities. Nothing sits in a warehouse, nothing ends up in landfill." + }, + { + "title": "Designed to age well", + "body": "Reinforced construction and natural finishes that soften and improve with every wear and wash." + } + ] + } + }, + { + "type": "newsletter-cta", + "props": { + "id": "newsletter-about", + "tagline": "Stay in the loop", + "heading": "Letters from the studio", + "subheading": "New collections, mill stories, and the occasional invitation. Twice a month.", + "buttonLabel": "Subscribe", + "endpoint": "", + "imageUrl": "https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80", + "layout": "split" + } + }, + { + "type": "footer", + "props": { + "id": "footer-about", + "brand": "Maison", + "tagline": "Considered essentials, made in small batches and built to last beyond the season.", + "columns": [ + { + "title": "Shop", + "links": [ + { + "label": "All", + "href": "/search" + }, + { + "label": "New", + "href": "/collections/new" + }, + { + "label": "Best sellers", + "href": "/collections/best" + } + ] + }, + { + "title": "About", + "links": [ + { + "label": "Our story", + "href": "/about" + }, + { + "label": "Materials", + "href": "/materials" + }, + { + "label": "Journal", + "href": "/journal" + } + ] + }, + { + "title": "Help", + "links": [ + { + "label": "Shipping", + "href": "/help/shipping" + }, + { + "label": "Returns", + "href": "/help/returns" + }, + { + "label": "Contact", + "href": "/contact" + } + ] + } + ], + "social": [ + { + "label": "Instagram", + "href": "#" + }, + { + "label": "Pinterest", + "href": "#" + }, + { + "label": "TikTok", + "href": "#" + } ], "showNewsletter": "no", "newsletterHeading": "Stay in touch", @@ -276,4 +870,4 @@ } ] } -} +} \ No newline at end of file diff --git a/components/ThemeProvider.tsx b/components/ThemeProvider.tsx index ca12c7d..1868912 100644 --- a/components/ThemeProvider.tsx +++ b/components/ThemeProvider.tsx @@ -13,7 +13,8 @@ export type ThemeProps = { mutedColor?: string; mutedForegroundColor?: string; borderColor?: string; - radius?: "none" | "sm" | "md" | "lg" | "xl" | "full"; + radius?: "none" | "sm" | "md" | "lg" | "xl"; + buttonRadius?: "none" | "sm" | "md" | "lg" | "xl" | "full"; shadow?: "none" | "sm" | "md" | "lg" | "xl"; maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "full"; }; @@ -24,6 +25,14 @@ const radiusMap: Record, string> = { md: "0.5rem", lg: "0.75rem", xl: "1rem", +}; + +const buttonRadiusMap: Record, string> = { + none: "0px", + sm: "0.25rem", + md: "0.5rem", + lg: "0.75rem", + xl: "1rem", full: "9999px", }; @@ -59,6 +68,7 @@ export function ThemeProvider({ mutedForegroundColor, borderColor, radius, + buttonRadius, shadow, children, }: ThemeProps & { children?: React.ReactNode }) { @@ -75,6 +85,7 @@ export function ThemeProvider({ if (mutedForegroundColor) vars["--muted-foreground"] = mutedForegroundColor; if (borderColor) vars["--border"] = borderColor; if (radius) vars["--radius"] = radiusMap[radius]; + if (buttonRadius) vars["--button-radius"] = buttonRadiusMap[buttonRadius]; if (shadow) vars["--shadow"] = shadowMap[shadow]; if (headerFont) vars["--font-header"] = `"${headerFont}", system-ui, sans-serif`; if (bodyFont) vars["--font-body"] = `"${bodyFont}", system-ui, sans-serif`; @@ -92,6 +103,7 @@ export function ThemeProvider({ mutedForegroundColor, borderColor, radius, + buttonRadius, shadow, ]); diff --git a/components/commerce/cart-drawer.tsx b/components/commerce/cart-drawer.tsx index 3c92a02..3cfd16d 100644 --- a/components/commerce/cart-drawer.tsx +++ b/components/commerce/cart-drawer.tsx @@ -84,7 +84,6 @@ const CartDrawer: React.FC = () => { diff --git a/components/commerce/collection.editor.tsx b/components/commerce/collection.editor.tsx index 2d6d265..5418181 100644 --- a/components/commerce/collection.editor.tsx +++ b/components/commerce/collection.editor.tsx @@ -1,5 +1,7 @@ import { ComponentConfig } from "@reacteditor/core"; import { FolderOpen } from "lucide-react"; +import { imageField } from "@reacteditor/plugin-media/field"; +import { frontendAiMediaAdapter } from "@/services/media-adapter"; import { CollectionView, type CollectionProps } from "@/components/commerce/collection"; export function createCollectionEditor(opts: { @@ -9,17 +11,240 @@ export function createCollectionEditor(opts: { label: "Collection page", icon: , category: "commerce", - defaultProps: { collection: null, showDescription: "no" }, + 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", ...opts.collectionField }, showDescription: { label: "Description", type: "radio", options: [ - { label: "Hide description", value: "no" }, - { label: "Show description", value: "yes" }, + { 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", + ...imageField({ adapter: frontendAiMediaAdapter }), + }, + 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) => , }; diff --git a/components/commerce/collection.tsx b/components/commerce/collection.tsx index c430b5c..faaba36 100644 --- a/components/commerce/collection.tsx +++ b/components/commerce/collection.tsx @@ -1,31 +1,456 @@ -import type { ShopifyCollection } from "@reacteditor/field-shopify"; -import { useCollectionProducts } from "@/hooks/use-shopify-collections"; -import { ProductCard } from "./product-card"; -import { Typography } from "@/components/Typography"; -import { Skeleton } from "@/components/ui/skeleton"; +import { useState, useCallback } from 'react'; +import { useParams } from 'react-router'; +import { ChevronDown, SlidersHorizontal, X } 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 { cn } from '@/lib/utils'; + +type FilterOption = { label: string }; +type ColorOption = { label: string; color: string }; export type CollectionProps = { collection: ShopifyCollection | null; - showDescription: "yes" | "no"; + 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 }[] }[]; }; -export function CollectionView({ - collection: selected, - showDescription, -}: CollectionProps) { - const handle = selected?.handle ?? ""; - const { collection, loading } = useCollectionProducts(handle, { first: 24 }); +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' }, +]; - if (!selected) { +const colClass: Record = { + '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 ( +
+ + {open &&
{children}
} +
+ ); +} + +function Checkbox({ checked, onChange, label }: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; +}) { + return ( + + ); +} + +// ─── 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; +}; + +function Sidebar({ + props, + active, + onChange, +}: { + props: CollectionProps; + active: ActiveFilters; + onChange: (patch: Partial) => 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] }); + } + + const hasActiveFilters = + active.availability || + active.productTypes.length > 0 || + active.vendors.length > 0 || + active.tags.length > 0 || + active.colors.length > 0 || + active.styles.length > 0 || + active.sizes.length > 0 || + active.materials.length > 0 || + active.minPrice !== '' || + active.maxPrice !== '' || + Object.values(active.metafieldValues).some((arr) => arr.length > 0); + + return ( + + ); +} + +// ─── 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 { handle: paramHandle } = useParams<{ handle?: string }>(); + const handle = selected?.handle ?? paramHandle ?? ''; + + const [sort, setSort] = useState(defaultSort); + const [reverse, setReverse] = useState(false); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + const [active, setActive] = useState({ + availability: false, + productTypes: [], + vendors: [], + tags: [], + colors: [], + styles: [], + sizes: [], + materials: [], + minPrice: '', + maxPrice: '', + metafieldValues: {}, + }); + + const patchActive = useCallback((patch: Partial) => { + setActive((prev) => ({ ...prev, ...patch })); + }, []); + + 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 && !paramHandle) { return (
- {showDescription === "yes" ? ( - - ) : null}
{Array.from({ length: 8 }).map((_, i) => ( @@ -37,39 +462,125 @@ export function CollectionView({ ); } - const products = (collection?.products as any[] | undefined) ?? []; - const description = collection?.description ?? selected.description; - return (
-
+ {/* Cover image */} + {showCoverImage === 'yes' && collectionImage && ( +
+ {collection?.title +
+ )} + + {/* Header */} +

Collection

- {collection?.title ?? selected.title} + {collection?.title ?? (selected as any)?.title ?? paramHandle} - {showDescription === "yes" && description ? ( - + {showDescription === 'yes' && description ? ( + {description} ) : null}
-
- {loading - ? Array.from({ length: 8 }).map((_, i) => ( - - )) - : products.map((p: any) => )} + {/* Mobile filter toggle */} +
+ +
- {!loading && products.length === 0 ? ( -
- This collection has no products yet. + {/* Mobile filter panel */} + {mobileFiltersOpen && ( +
+
- ) : null} + )} + +
+ {/* Desktop sidebar */} +
+ +
+ + {/* Product area */} +
+ {/* Sort + count bar */} +
+

+ {loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`} +

+
+ +
+
+ + {/* Grid */} +
+ {loading + ? Array.from({ length: limit }).map((_, i) => ( + + )) + : products.map((p: any) => )} +
+ + {!loading && products.length === 0 && ( +
+ No products found in this collection. +
+ )} + + {hasNextPage && !loading && ( +
+ +
+ )} +
+
); diff --git a/components/commerce/products-carousel.tsx b/components/commerce/products-carousel.tsx index ae4af05..a2412d7 100644 --- a/components/commerce/products-carousel.tsx +++ b/components/commerce/products-carousel.tsx @@ -50,7 +50,7 @@ export function ProductsCarousel({ const data = await getCollectionProducts(collection.handle, { first: limit, }); - if (!cancelled) setProducts(data?.products ?? []); + if (!cancelled) setProducts(data?.collection?.products ?? []); } else { const data = await getProducts({ first: limit, diff --git a/components/commerce/products-grid.tsx b/components/commerce/products-grid.tsx index 9a1543c..f153f25 100644 --- a/components/commerce/products-grid.tsx +++ b/components/commerce/products-grid.tsx @@ -42,7 +42,7 @@ export function ProductsGrid({ const data = await getCollectionProducts(collection.handle, { first: limit, }); - if (!cancelled) setProducts(data?.products ?? []); + if (!cancelled) setProducts(data?.collection?.products ?? []); } else { const data = await getProducts({ first: limit, sortKey: "BEST_SELLING" }); if (!cancelled) setProducts(data ?? []); diff --git a/components/commerce/search-products.editor.tsx b/components/commerce/search-products.editor.tsx new file mode 100644 index 0000000..4383195 --- /dev/null +++ b/components/commerce/search-products.editor.tsx @@ -0,0 +1,242 @@ +import { ComponentConfig } from '@reacteditor/core'; +import { Search } from 'lucide-react'; +import { SearchProductsView, type SearchProductsProps } from '@/components/commerce/search-products'; + +export const searchProductsEditor: ComponentConfig = { + label: 'Search & filter', + icon: , + 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) => , +}; diff --git a/components/commerce/search-products.tsx b/components/commerce/search-products.tsx new file mode 100644 index 0000000..ec92872 --- /dev/null +++ b/components/commerce/search-products.tsx @@ -0,0 +1,562 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useSearchParams } from 'react-router'; +import { ChevronDown, SlidersHorizontal, X } 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 { 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 = { + '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 ( +
+ + {open &&
{children}
} +
+ ); +} + +function Checkbox({ checked, onChange, label }: { + checked: boolean; + onChange: (v: boolean) => void; + label: string; +}) { + return ( + + ); +} + +// ─── 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; +}; + +function Sidebar({ + props, + active, + onChange, +}: { + props: SearchProductsProps; + active: ActiveFilters; + onChange: (patch: Partial) => 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] }); + } + + const hasActiveFilters = + active.availability || + active.productTypes.length > 0 || + active.vendors.length > 0 || + active.tags.length > 0 || + active.colors.length > 0 || + active.styles.length > 0 || + active.sizes.length > 0 || + active.materials.length > 0 || + active.minPrice !== '' || + active.maxPrice !== '' || + Object.values(active.metafieldValues).some((arr) => arr.length > 0); + + return ( + + ); +} + +// ─── Main component ────────────────────────────────────────────────────────── + +export function SearchProductsView(props: SearchProductsProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const initialQ = searchParams.get('q') ?? ''; + + const [query, setQuery] = useState(initialQ); + const [inputValue, setInputValue] = useState(initialQ); + const [sort, setSort] = useState(props.defaultSort); + const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); + const [active, setActive] = useState({ + availability: false, + productTypes: [], + vendors: [], + tags: [], + colors: [], + styles: [], + sizes: [], + materials: [], + minPrice: '', + maxPrice: '', + metafieldValues: {}, + }); + + const patchActive = useCallback((patch: Partial) => { + setActive((prev) => ({ ...prev, ...patch })); + }, []); + + 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); + if (query) params.set('q', query); else params.delete('q'); + setSearchParams(params, { replace: true }); + }, [query]); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setQuery(inputValue.trim()); + }; + + return ( +
+
+ + {/* Page header */} +
+ {props.heading && ( +

+ {props.heading} +

+ )} + {props.subheading && ( +

{props.subheading}

+ )} + {/* Search bar (mobile only – desktop version lives in the product area) */} +
+ 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" + /> + +
+
+ + {/* Mobile filter toggle */} +
+ + +
+ + {/* Mobile filter panel */} + {mobileFiltersOpen && ( +
+ +
+ )} + +
+ {/* Desktop sidebar */} +
+ +
+ + {/* Product area */} +
+ {/* Search bar (desktop only) */} +
+ 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" + /> + +
+ + {/* Sort + count bar */} +
+

+ {loading ? 'Loading…' : `${products.length} product${products.length === 1 ? '' : 's'}`} +

+
+ +
+
+ + {error && ( +

+ {error} +

+ )} + + {/* Grid */} +
+ {loading + ? Array.from({ length: props.limit }).map((_, i) => ( + + )) + : products.map((p) => )} +
+ + {!loading && products.length === 0 && !error && ( +
+ No products found.{query ? ` Try a different search term.` : ''} +
+ )} + + {hasNextPage && !loading && ( +
+ +
+ )} +
+
+
+
+ ); +} diff --git a/components/navigation/navigation.editor.tsx b/components/navigation/navigation.editor.tsx index e8aaa1c..809da55 100644 --- a/components/navigation/navigation.editor.tsx +++ b/components/navigation/navigation.editor.tsx @@ -13,9 +13,9 @@ export const navigationEditor: ComponentConfig = { brand: "Maison", logo: "", links: [ - { label: "Shop", href: "/collections" }, - { label: "Lookbook", href: "/lookbook" }, - { label: "Journal", href: "/journal" }, + { label: "Mens", href: "/collections/mens" }, + { label: "Womens", href: "/collections/womens" }, + { label: "Shop", href: "/search" }, { label: "About", href: "/about" }, ], showSearch: "yes", diff --git a/components/navigation/navigation.tsx b/components/navigation/navigation.tsx index 0e46cce..794b206 100644 --- a/components/navigation/navigation.tsx +++ b/components/navigation/navigation.tsx @@ -80,12 +80,13 @@ export function Navigation({
{showSearch === "yes" && ( - + )} {showCart === "yes" && (