Compare commits
11
Commits
66a318d735
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f11a764426 | ||
|
|
63ecc5e284 | ||
|
|
909d99251a | ||
|
|
528f2d2ab3 | ||
|
|
ff2de13f99 | ||
|
|
ef9e10256d | ||
|
|
ed346476e5 | ||
|
|
a813d3705e | ||
|
|
8db5baf673 | ||
|
|
aa58af410d | ||
|
|
ef550e9b55 |
@@ -0,0 +1,12 @@
|
|||||||
|
# Shopify Storefront
|
||||||
|
NEXT_PUBLIC_SHOPIFY_DOMAIN=mock.shop
|
||||||
|
# NEXT_PUBLIC_SHOPIFY_PUBLIC_ACCESS_TOKEN=
|
||||||
|
# NEXT_PUBLIC_SHOPIFY_API_VERSION=2026-07
|
||||||
|
|
||||||
|
# Store assistant — set to 1 to show the Ask launcher; anything else hides it
|
||||||
|
NEXT_PUBLIC_ENABLE_AI=0
|
||||||
|
|
||||||
|
# Store assistant (/api/chat) — https://openrouter.ai/keys
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
# Optional; defaults to openai/gpt-5.6-luna-pro
|
||||||
|
# OPENROUTER_MODEL=
|
||||||
+11
@@ -6,3 +6,14 @@ dist
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Build caches
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Yarn (Berry) — keep the lockfile and .yarnrc.yml, ignore local state
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
.pnp.*
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
# Yarn quarantines npm releases younger than `npmMinimalAgeGate` (24h by
|
||||||
|
# default). The @reacteditor alpha channel and the Hydrogen preview build are
|
||||||
|
# both pinned to deliberately-recent versions, so preapprove just those rather
|
||||||
|
# than dropping the age gate for the whole dependency tree.
|
||||||
|
npmPreapprovedPackages:
|
||||||
|
- "@reacteditor/*"
|
||||||
|
- "@shopify/hydrogen"
|
||||||
+53
-1
@@ -1 +1,53 @@
|
|||||||
{}
|
{
|
||||||
|
"header": {
|
||||||
|
"props": {
|
||||||
|
"storeName": "Shop",
|
||||||
|
"logoUrl": "",
|
||||||
|
"showAnnouncement": "yes",
|
||||||
|
"announcement": "Free shipping on orders over $200",
|
||||||
|
"announcementUrl": "",
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"label": "Products",
|
||||||
|
"url": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Collections",
|
||||||
|
"url": "/collections"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"props": {
|
||||||
|
"storeName": "Shop",
|
||||||
|
"logoUrl": "",
|
||||||
|
"copyright": "\u00a9 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": "#"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
-705
@@ -1,705 +0,0 @@
|
|||||||
{
|
|
||||||
"/": {
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Pulse — Made to Move",
|
|
||||||
"headerFont": "Archivo Black",
|
|
||||||
"headerFontWeight": "400",
|
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "navigation",
|
|
||||||
"props": {
|
|
||||||
"id": "nav-home",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" },
|
|
||||||
{ "label": "Recover", "href": "/collections/recover" },
|
|
||||||
{ "label": "Sale", "href": "/collections/sale" },
|
|
||||||
{ "label": "About", "href": "/about" }
|
|
||||||
],
|
|
||||||
"showSearch": "yes",
|
|
||||||
"showAccount": "yes",
|
|
||||||
"showCart": "yes",
|
|
||||||
"sticky": "yes",
|
|
||||||
"tone": "default"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "hero",
|
|
||||||
"props": {
|
|
||||||
"id": "hero-home",
|
|
||||||
"tagline": "Spring 2026",
|
|
||||||
"heading": "Made to move.",
|
|
||||||
"subheading": "Performance gear engineered for athletes who train like they mean it. Built light. Built honest. Built for the next mile.",
|
|
||||||
"buttons": [
|
|
||||||
{ "label": "Shop the kit", "href": "/search", "variant": "primary" },
|
|
||||||
{ "label": "Our story", "href": "/about", "variant": "secondary" }
|
|
||||||
],
|
|
||||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/athletic-assortment.jpeg",
|
|
||||||
"align": "left",
|
|
||||||
"height": "lg",
|
|
||||||
"tone": "light"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "products-carousel",
|
|
||||||
"props": {
|
|
||||||
"id": "carousel-home",
|
|
||||||
"tagline": "Just dropped",
|
|
||||||
"heading": "New arrivals",
|
|
||||||
"subheading": "Fresh kit for the season ahead.",
|
|
||||||
"limit": 12,
|
|
||||||
"slidesPerView": "4",
|
|
||||||
"ctaLabel": "Shop new",
|
|
||||||
"ctaHref": "/collections/new"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "hero",
|
|
||||||
"props": {
|
|
||||||
"id": "cover-home-performance",
|
|
||||||
"tagline": "Performance Series",
|
|
||||||
"heading": "Engineered to outlast you.",
|
|
||||||
"subheading": "Tested on track, trail, and treadmill. Every piece earns its place.",
|
|
||||||
"buttons": [],
|
|
||||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-performance.jpeg",
|
|
||||||
"align": "left",
|
|
||||||
"height": "lg",
|
|
||||||
"tone": "light"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "features",
|
|
||||||
"props": {
|
|
||||||
"id": "features-home",
|
|
||||||
"tagline": "Why Pulse",
|
|
||||||
"heading": "Three rules. No exceptions.",
|
|
||||||
"subheading": "",
|
|
||||||
"columns": "3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Engineered",
|
|
||||||
"body": "Every fabric, every seam, every fit decision starts with athletes on a track. Lab work comes after."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Field tested",
|
|
||||||
"body": "Twelve weeks. Two race blocks. One full season. Nothing ships without that on the record."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Guaranteed",
|
|
||||||
"body": "If it fails before its time, we replace it. If it fails on race day, we apologize and replace it."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "hero",
|
|
||||||
"props": {
|
|
||||||
"id": "cover-home-discipline",
|
|
||||||
"tagline": "Discipline",
|
|
||||||
"heading": "The work happens before sunrise.",
|
|
||||||
"subheading": "We make the kit. You do the work. Five-thirty isn't early — it's the only honest hour.",
|
|
||||||
"buttons": [],
|
|
||||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-discipline.jpeg",
|
|
||||||
"align": "left",
|
|
||||||
"height": "lg",
|
|
||||||
"tone": "light"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "collection-grid",
|
|
||||||
"props": {
|
|
||||||
"id": "collections-home",
|
|
||||||
"tagline": "Shop by sport",
|
|
||||||
"heading": "Built for what you actually do.",
|
|
||||||
"subheading": "",
|
|
||||||
"layout": "tiles",
|
|
||||||
"limit": 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "testimonials",
|
|
||||||
"props": {
|
|
||||||
"id": "testimonials-home",
|
|
||||||
"tagline": "From the field",
|
|
||||||
"heading": "Athletes on Pulse",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"quote": "Took the shell to Chamonix and back. Still smells, still works, still cheaper than therapy.",
|
|
||||||
"author": "Mateo S.",
|
|
||||||
"role": "Sub-3 marathoner / Boulder",
|
|
||||||
"avatar": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"quote": "I have one drawer of running gear. The Pulse half is the half I actually wear.",
|
|
||||||
"author": "Priya N.",
|
|
||||||
"role": "Ultra runner / Portland",
|
|
||||||
"avatar": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"quote": "Wore the shorts for an entire 16-week block. They held. That's the whole review.",
|
|
||||||
"author": "Jonas R.",
|
|
||||||
"role": "Track coach / Berlin",
|
|
||||||
"avatar": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "newsletter-cta",
|
|
||||||
"props": {
|
|
||||||
"id": "newsletter-home",
|
|
||||||
"tagline": "Field notes",
|
|
||||||
"heading": "Weekly notes from the lab.",
|
|
||||||
"subheading": "Test results, training blocks, gear we cut and gear we kept. Every Monday at 5:30am.",
|
|
||||||
"buttonLabel": "Subscribe",
|
|
||||||
"endpoint": "",
|
|
||||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/newsletter.png",
|
|
||||||
"layout": "split"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer-home",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"title": "Shop",
|
|
||||||
"links": [
|
|
||||||
{ "label": "All gear", "href": "/search" },
|
|
||||||
{ "label": "New", "href": "/collections/new" },
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" },
|
|
||||||
{ "label": "Sale", "href": "/collections/sale" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "About",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Our story", "href": "/about" },
|
|
||||||
{ "label": "The lab", "href": "/lab" },
|
|
||||||
{ "label": "Athletes", "href": "/athletes" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Help",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Shipping", "href": "/help/shipping" },
|
|
||||||
{ "label": "Returns", "href": "/help/returns" },
|
|
||||||
{ "label": "Lifetime guarantee", "href": "/help/guarantee" },
|
|
||||||
{ "label": "Contact", "href": "/contact" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Legal",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Terms", "href": "/terms" },
|
|
||||||
{ "label": "Privacy", "href": "/privacy" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"social": [
|
|
||||||
{ "label": "Instagram", "href": "#" },
|
|
||||||
{ "label": "Strava", "href": "#" },
|
|
||||||
{ "label": "YouTube", "href": "#" }
|
|
||||||
],
|
|
||||||
"showNewsletter": "no",
|
|
||||||
"newsletterHeading": "",
|
|
||||||
"newsletterEndpoint": "",
|
|
||||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"/products/:handle": {
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Pulse",
|
|
||||||
"headerFont": "Archivo Black",
|
|
||||||
"headerFontWeight": "400",
|
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "navigation",
|
|
||||||
"props": {
|
|
||||||
"id": "nav-product",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" },
|
|
||||||
{ "label": "Recover", "href": "/collections/recover" },
|
|
||||||
{ "label": "Sale", "href": "/collections/sale" },
|
|
||||||
{ "label": "About", "href": "/about" }
|
|
||||||
],
|
|
||||||
"showSearch": "yes",
|
|
||||||
"showAccount": "yes",
|
|
||||||
"showCart": "yes",
|
|
||||||
"sticky": "yes",
|
|
||||||
"tone": "default"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "product-details",
|
|
||||||
"props": {
|
|
||||||
"id": "product-details",
|
|
||||||
"product": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "products-carousel",
|
|
||||||
"props": {
|
|
||||||
"id": "carousel-related",
|
|
||||||
"tagline": "Pairs well with",
|
|
||||||
"heading": "Complete the kit",
|
|
||||||
"limit": 8,
|
|
||||||
"slidesPerView": "4",
|
|
||||||
"ctaLabel": "Shop all",
|
|
||||||
"ctaHref": "/collections"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer-product",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"title": "Shop",
|
|
||||||
"links": [
|
|
||||||
{ "label": "All gear", "href": "/search" },
|
|
||||||
{ "label": "New", "href": "/collections/new" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Help",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Shipping", "href": "/help/shipping" },
|
|
||||||
{ "label": "Returns", "href": "/help/returns" },
|
|
||||||
{ "label": "Lifetime guarantee", "href": "/help/guarantee" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"social": [
|
|
||||||
{ "label": "Instagram", "href": "#" },
|
|
||||||
{ "label": "Strava", "href": "#" }
|
|
||||||
],
|
|
||||||
"showNewsletter": "no",
|
|
||||||
"newsletterHeading": "",
|
|
||||||
"newsletterEndpoint": "",
|
|
||||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"/collections/:handle": {
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Collection — Pulse",
|
|
||||||
"headerFont": "Archivo Black",
|
|
||||||
"headerFontWeight": "400",
|
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "navigation",
|
|
||||||
"props": {
|
|
||||||
"id": "nav-collection",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" },
|
|
||||||
{ "label": "Recover", "href": "/collections/recover" },
|
|
||||||
{ "label": "Sale", "href": "/collections/sale" },
|
|
||||||
{ "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": "yes",
|
|
||||||
"productTypeOptions": [
|
|
||||||
{ "label": "Tops" },
|
|
||||||
{ "label": "Shorts" },
|
|
||||||
{ "label": "Tights" },
|
|
||||||
{ "label": "Outerwear" },
|
|
||||||
{ "label": "Shoes" },
|
|
||||||
{ "label": "Accessories" }
|
|
||||||
],
|
|
||||||
"showVendor": "no",
|
|
||||||
"vendorOptions": [],
|
|
||||||
"showTags": "no",
|
|
||||||
"tagOptions": [],
|
|
||||||
"showColor": "yes",
|
|
||||||
"colorOptions": [
|
|
||||||
{ "label": "Black", "color": "#111111" },
|
|
||||||
{ "label": "White", "color": "#ffffff" },
|
|
||||||
{ "label": "Grey", "color": "#707072" },
|
|
||||||
{ "label": "Volt", "color": "#d6ff3f" },
|
|
||||||
{ "label": "Sodium", "color": "#fa5400" }
|
|
||||||
],
|
|
||||||
"showStyle": "no",
|
|
||||||
"styleOptions": [],
|
|
||||||
"showSize": "yes",
|
|
||||||
"sizeOptions": [
|
|
||||||
{ "label": "XS" },
|
|
||||||
{ "label": "S" },
|
|
||||||
{ "label": "M" },
|
|
||||||
{ "label": "L" },
|
|
||||||
{ "label": "XL" },
|
|
||||||
{ "label": "XXL" }
|
|
||||||
],
|
|
||||||
"showMaterial": "no",
|
|
||||||
"materialOptions": [],
|
|
||||||
"metafieldFilters": []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer-collection",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"title": "Shop",
|
|
||||||
"links": [
|
|
||||||
{ "label": "All gear", "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": "Strava", "href": "#" }
|
|
||||||
],
|
|
||||||
"showNewsletter": "no",
|
|
||||||
"newsletterHeading": "",
|
|
||||||
"newsletterEndpoint": "",
|
|
||||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"/search": {
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "Shop — Pulse",
|
|
||||||
"headerFont": "Archivo Black",
|
|
||||||
"headerFontWeight": "400",
|
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "navigation",
|
|
||||||
"props": {
|
|
||||||
"id": "nav-search",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" },
|
|
||||||
{ "label": "Recover", "href": "/collections/recover" },
|
|
||||||
{ "label": "Sale", "href": "/collections/sale" },
|
|
||||||
{ "label": "About", "href": "/about" }
|
|
||||||
],
|
|
||||||
"showSearch": "yes",
|
|
||||||
"showAccount": "yes",
|
|
||||||
"showCart": "yes",
|
|
||||||
"sticky": "yes",
|
|
||||||
"tone": "default"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "search-products",
|
|
||||||
"props": {
|
|
||||||
"id": "search-products",
|
|
||||||
"heading": "All gear.",
|
|
||||||
"subheading": "Filter by sport, fabric, fit, color. Or sort by what's been beaten the hardest.",
|
|
||||||
"columns": "4",
|
|
||||||
"limit": 24,
|
|
||||||
"showAvailability": "yes",
|
|
||||||
"showPriceRange": "yes",
|
|
||||||
"showProductType": "yes",
|
|
||||||
"showVendor": "no",
|
|
||||||
"showTags": "yes",
|
|
||||||
"metafieldFilters": [],
|
|
||||||
"defaultSort": "BEST_SELLING",
|
|
||||||
"productTypeOptions": [
|
|
||||||
{ "label": "Tops" },
|
|
||||||
{ "label": "Shorts" },
|
|
||||||
{ "label": "Tights" },
|
|
||||||
{ "label": "Outerwear" },
|
|
||||||
{ "label": "Shoes" },
|
|
||||||
{ "label": "Accessories" }
|
|
||||||
],
|
|
||||||
"vendorOptions": [],
|
|
||||||
"tagOptions": [
|
|
||||||
{ "label": "New" },
|
|
||||||
{ "label": "Race day" },
|
|
||||||
{ "label": "Field tested" },
|
|
||||||
{ "label": "Sale" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer-search",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"title": "Shop",
|
|
||||||
"links": [
|
|
||||||
{ "label": "All gear", "href": "/search" },
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Help",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Shipping", "href": "/help/shipping" },
|
|
||||||
{ "label": "Returns", "href": "/help/returns" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"social": [
|
|
||||||
{ "label": "Instagram", "href": "#" },
|
|
||||||
{ "label": "Strava", "href": "#" }
|
|
||||||
],
|
|
||||||
"showNewsletter": "no",
|
|
||||||
"newsletterHeading": "",
|
|
||||||
"newsletterEndpoint": "",
|
|
||||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"/about": {
|
|
||||||
"root": {
|
|
||||||
"props": {
|
|
||||||
"title": "About — Pulse",
|
|
||||||
"headerFont": "Archivo Black",
|
|
||||||
"headerFontWeight": "400",
|
|
||||||
"bodyFont": "Inter",
|
|
||||||
"primaryColor": "#111111",
|
|
||||||
"secondaryColor": "#707072",
|
|
||||||
"accentColor": "#f5f5f5",
|
|
||||||
"bgColor": "#ffffff",
|
|
||||||
"fgColor": "#111111",
|
|
||||||
"mutedColor": "#f5f5f5",
|
|
||||||
"radius": "xl",
|
|
||||||
"buttonRadius": "full",
|
|
||||||
"shadow": "none",
|
|
||||||
"maxWidth": "2xl"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"type": "navigation",
|
|
||||||
"props": {
|
|
||||||
"id": "nav-about",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" },
|
|
||||||
{ "label": "Recover", "href": "/collections/recover" },
|
|
||||||
{ "label": "Sale", "href": "/collections/sale" },
|
|
||||||
{ "label": "About", "href": "/about" }
|
|
||||||
],
|
|
||||||
"showSearch": "yes",
|
|
||||||
"showAccount": "yes",
|
|
||||||
"showCart": "yes",
|
|
||||||
"sticky": "yes",
|
|
||||||
"tone": "default"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "hero",
|
|
||||||
"props": {
|
|
||||||
"id": "hero-about",
|
|
||||||
"tagline": "The lab",
|
|
||||||
"heading": "We don't make activewear. We make tools.",
|
|
||||||
"subheading": "Pulse started in a converted warehouse with three coaches, two athletes, and a whiteboard full of things they wished worked better. Eight years later, the whiteboard is still there. So is the standard.",
|
|
||||||
"buttons": [
|
|
||||||
{ "label": "Shop the kit", "href": "/search", "variant": "primary" }
|
|
||||||
],
|
|
||||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/hero-about.png",
|
|
||||||
"align": "left",
|
|
||||||
"height": "lg",
|
|
||||||
"tone": "light"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "features",
|
|
||||||
"props": {
|
|
||||||
"id": "features-about",
|
|
||||||
"tagline": "What we believe",
|
|
||||||
"heading": "Three rules. No exceptions.",
|
|
||||||
"subheading": "",
|
|
||||||
"columns": "3",
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"title": "Engineered",
|
|
||||||
"body": "Every fabric, every seam, every fit decision starts with athletes on a track. Lab work comes after, if at all."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Field tested",
|
|
||||||
"body": "Twelve weeks of training. Two race blocks. One full season. Nothing leaves the lab without that on the record."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Guaranteed",
|
|
||||||
"body": "If it fails before its time, we replace it. If it fails on race day, we apologize and replace it. That's the deal."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "hero",
|
|
||||||
"props": {
|
|
||||||
"id": "cover-about-performance",
|
|
||||||
"tagline": "Used up is the goal",
|
|
||||||
"heading": "Designed to be worn out, not preserved.",
|
|
||||||
"subheading": "If you're babying your kit, we did something wrong. Bring it back when it's done — we'll send the next one.",
|
|
||||||
"buttons": [],
|
|
||||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/cover-performance.jpeg",
|
|
||||||
"align": "left",
|
|
||||||
"height": "lg",
|
|
||||||
"tone": "light"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "newsletter-cta",
|
|
||||||
"props": {
|
|
||||||
"id": "newsletter-about",
|
|
||||||
"tagline": "Field notes",
|
|
||||||
"heading": "Weekly notes from the lab.",
|
|
||||||
"subheading": "Test results, training blocks, gear we cut and gear we kept. Every Monday at 5:30am.",
|
|
||||||
"buttonLabel": "Subscribe",
|
|
||||||
"endpoint": "",
|
|
||||||
"imageUrl": "https://supabase.frontend-ai.com/storage/v1/object/public/assets/themes/pulse-nike/newsletter.png",
|
|
||||||
"layout": "split"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "footer",
|
|
||||||
"props": {
|
|
||||||
"id": "footer-about",
|
|
||||||
"brand": "PULSE",
|
|
||||||
"tagline": "Performance gear for athletes who break their kit before they break themselves.",
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"title": "Shop",
|
|
||||||
"links": [
|
|
||||||
{ "label": "All gear", "href": "/search" },
|
|
||||||
{ "label": "New", "href": "/collections/new" },
|
|
||||||
{ "label": "Run", "href": "/collections/run" },
|
|
||||||
{ "label": "Train", "href": "/collections/train" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "About",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Our story", "href": "/about" },
|
|
||||||
{ "label": "The lab", "href": "/lab" },
|
|
||||||
{ "label": "Athletes", "href": "/athletes" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Help",
|
|
||||||
"links": [
|
|
||||||
{ "label": "Shipping", "href": "/help/shipping" },
|
|
||||||
{ "label": "Returns", "href": "/help/returns" },
|
|
||||||
{ "label": "Lifetime guarantee", "href": "/help/guarantee" },
|
|
||||||
{ "label": "Contact", "href": "/contact" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"social": [
|
|
||||||
{ "label": "Instagram", "href": "#" },
|
|
||||||
{ "label": "Strava", "href": "#" },
|
|
||||||
{ "label": "YouTube", "href": "#" }
|
|
||||||
],
|
|
||||||
"showNewsletter": "no",
|
|
||||||
"newsletterHeading": "",
|
|
||||||
"newsletterEndpoint": "",
|
|
||||||
"copyright": "© 2026 Pulse. Built to be replaced when used up."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { Render } from "@reacteditor/core";
|
|
||||||
import { appConfig } from "@/editor.config";
|
|
||||||
import resolveRoute from "@/lib/resolve-route";
|
|
||||||
import schema from "@/app.schema.json";
|
|
||||||
import globals from "@/app.globals.json";
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const params = useParams();
|
|
||||||
const segments = Array.isArray(params?.slug) ? (params.slug as string[]) : [];
|
|
||||||
const { key } = resolveRoute(segments);
|
|
||||||
const { root, content } = (schema as any)[key] ?? {};
|
|
||||||
const data = { root, content, globals };
|
|
||||||
return <Render config={appConfig as any} data={data} />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
|
// Editor for /about. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets, and `../page.json` is the
|
||||||
|
// very file publishing writes back to.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/about" page={pageData} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "About — Shop",
|
||||||
|
"description": "About Shop.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "content-section",
|
||||||
|
"props": {
|
||||||
|
"id": "about-intro",
|
||||||
|
"eyebrow": "Our story",
|
||||||
|
"heading": "About",
|
||||||
|
"body": "Tell your store’s story here.\n\nBlank lines start a new paragraph. Everything on this page is editable at /editor?route=/about.",
|
||||||
|
"imageUrl": "",
|
||||||
|
"imageAlt": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export const metadata = pageMetadata(page, { path: '/about' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { streamText, tool, convertToModelMessages, stepCountIs } from 'ai';
|
||||||
|
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
searchProducts,
|
||||||
|
getProduct,
|
||||||
|
getProductsPage,
|
||||||
|
getCollections,
|
||||||
|
getCollectionProductsPage,
|
||||||
|
isDefaultTitleOption,
|
||||||
|
isDefaultTitleSelection,
|
||||||
|
} from '@/services/shopify/catalog';
|
||||||
|
|
||||||
|
// Streaming needs the Node runtime here because the Storefront helpers run
|
||||||
|
// server-side on each tool call.
|
||||||
|
export const maxDuration = 30;
|
||||||
|
|
||||||
|
const MODEL = process.env.OPENROUTER_MODEL ?? 'openai/gpt-5.6-luna-pro';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are the shopping assistant for an online store built on Shopify.
|
||||||
|
|
||||||
|
You help shoppers find products, compare options, and understand what the store
|
||||||
|
carries. You have tools that read live catalogue data — always use them rather
|
||||||
|
than guessing, and never invent products, prices, availability, or policies.
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Call a tool whenever the answer depends on catalogue data. If a shopper asks
|
||||||
|
something vague like "what do you have?", call listCollections or
|
||||||
|
searchCatalogue to ground your answer.
|
||||||
|
- Prices returned by the tools are in the store's currency; show them as given.
|
||||||
|
- Link products as /products/{handle} and collections as /collections/{handle}
|
||||||
|
so the shopper can click through.
|
||||||
|
- Keep replies short and conversational — a sentence or two plus a compact list.
|
||||||
|
Do not repeat the raw tool output; the interface already shows it.
|
||||||
|
- If a tool returns nothing, say so plainly and suggest a different search.
|
||||||
|
- You cannot place orders, change carts, process payments, or look up customer
|
||||||
|
or order data. Say so and point the shopper to the relevant page instead.`;
|
||||||
|
|
||||||
|
// Trim the Storefront payloads to what the model actually needs to answer.
|
||||||
|
const summariseProduct = (product: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
handle: string;
|
||||||
|
description?: string;
|
||||||
|
productType?: string;
|
||||||
|
tags?: string[];
|
||||||
|
priceRange: { minVariantPrice: { amount: string; currencyCode: string } };
|
||||||
|
variants?: {
|
||||||
|
edges: Array<{
|
||||||
|
node: {
|
||||||
|
title: string;
|
||||||
|
availableForSale: boolean;
|
||||||
|
selectedOptions?: Array<{ name: string; value: string }>;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
images?: { edges: Array<{ node: { url: string } }> };
|
||||||
|
options?: Array<{ name: string; values: string[] }>;
|
||||||
|
}) => ({
|
||||||
|
title: product.title,
|
||||||
|
handle: product.handle,
|
||||||
|
url: `/products/${product.handle}`,
|
||||||
|
price: `${product.priceRange.minVariantPrice.amount} ${product.priceRange.minVariantPrice.currencyCode}`,
|
||||||
|
image: product.images?.edges[0]?.node.url ?? null,
|
||||||
|
description: product.description?.slice(0, 300) ?? null,
|
||||||
|
productType: product.productType || null,
|
||||||
|
tags: product.tags ?? [],
|
||||||
|
options: product.options
|
||||||
|
?.filter((option) => !isDefaultTitleOption(option))
|
||||||
|
.map((option) => ({
|
||||||
|
name: option.name,
|
||||||
|
values: option.values,
|
||||||
|
})),
|
||||||
|
inStock: product.variants?.edges.some((edge) => edge.node.availableForSale),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: 'OPENROUTER_API_KEY is not configured.' }),
|
||||||
|
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { messages } = await req.json();
|
||||||
|
const openrouter = createOpenRouter({ apiKey });
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
// `reasoning` asks OpenRouter to stream the model's thinking; the UI
|
||||||
|
// renders it via the Reasoning component.
|
||||||
|
model: openrouter(MODEL, { reasoning: { enabled: true, effort: 'medium' } }),
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
messages: await convertToModelMessages(messages),
|
||||||
|
// Let the model call a tool, read the result, then answer.
|
||||||
|
stopWhen: stepCountIs(5),
|
||||||
|
tools: {
|
||||||
|
searchCatalogue: tool({
|
||||||
|
description:
|
||||||
|
'Search the store for products matching a term. Use for any question about what the store sells.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
query: z
|
||||||
|
.string()
|
||||||
|
.describe('Search terms, e.g. "green hoodie" or "jacket".'),
|
||||||
|
limit: z.number().int().min(1).max(10).default(5),
|
||||||
|
}),
|
||||||
|
execute: async ({ query, limit }) => {
|
||||||
|
const { products, totalCount } = await searchProducts({
|
||||||
|
query,
|
||||||
|
first: limit,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
totalCount,
|
||||||
|
products: products.map(summariseProduct),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
getProductDetails: tool({
|
||||||
|
description:
|
||||||
|
'Get full details for one product by its handle, including options, variants, and stock.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
handle: z
|
||||||
|
.string()
|
||||||
|
.describe('The product handle, e.g. "flowguard-jacket".'),
|
||||||
|
}),
|
||||||
|
execute: async ({ handle }) => {
|
||||||
|
const product = await getProduct(handle);
|
||||||
|
if (!product) return { found: false, handle };
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
...summariseProduct(product),
|
||||||
|
variants: product.variants.edges.slice(0, 25).map(({ node }) => ({
|
||||||
|
title: node.title,
|
||||||
|
available: node.availableForSale,
|
||||||
|
price: `${node.price.amount} ${node.price.currencyCode}`,
|
||||||
|
options: node.selectedOptions?.filter(
|
||||||
|
(option) => !isDefaultTitleSelection(option)
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
listCollections: tool({
|
||||||
|
description:
|
||||||
|
'List the store\'s collections. Use when the shopper asks what categories or ranges exist.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
limit: z.number().int().min(1).max(25).default(10),
|
||||||
|
}),
|
||||||
|
execute: async ({ limit }) => {
|
||||||
|
const collections = await getCollections(limit);
|
||||||
|
return {
|
||||||
|
collections: collections.map((collection) => ({
|
||||||
|
title: collection.title,
|
||||||
|
handle: collection.handle,
|
||||||
|
url: `/collections/${collection.handle}`,
|
||||||
|
description: collection.description?.slice(0, 200) ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
getCollectionProducts: tool({
|
||||||
|
description:
|
||||||
|
'List the products inside one collection, by collection handle.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
handle: z.string().describe('The collection handle, e.g. "men".'),
|
||||||
|
limit: z.number().int().min(1).max(20).default(8),
|
||||||
|
}),
|
||||||
|
execute: async ({ handle, limit }) => {
|
||||||
|
const page = await getCollectionProductsPage(handle, { first: limit });
|
||||||
|
if (!page.collection) return { found: false, handle };
|
||||||
|
|
||||||
|
return {
|
||||||
|
found: true,
|
||||||
|
collection: page.collection.title,
|
||||||
|
url: `/collections/${handle}`,
|
||||||
|
products: page.products.map(summariseProduct),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
browseProducts: tool({
|
||||||
|
description:
|
||||||
|
'Browse the newest products when the shopper has no specific search term.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
limit: z.number().int().min(1).max(20).default(8),
|
||||||
|
}),
|
||||||
|
execute: async ({ limit }) => {
|
||||||
|
const page = await getProductsPage({
|
||||||
|
first: limit,
|
||||||
|
sortKey: 'CREATED_AT',
|
||||||
|
reverse: true,
|
||||||
|
});
|
||||||
|
return { products: page.products.map(summariseProduct) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// sendReasoning forwards reasoning parts to the client; without it the
|
||||||
|
// stream carries text and tool calls only.
|
||||||
|
return result.toUIMessageStreamResponse({ sendReasoning: true });
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { NextRequest } from "next/server";
|
|
||||||
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
|
|
||||||
|
|
||||||
// DELETE /api/media/:id — remove a media asset.
|
|
||||||
export async function DELETE(
|
|
||||||
_req: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ id: string }> },
|
|
||||||
) {
|
|
||||||
const { id } = await params;
|
|
||||||
const upstream = await fetch(
|
|
||||||
`${CLOUD_BASE}/api/media/${encodeURIComponent(id)}`,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { "x-api-key": FRONTEND_API_KEY },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Response(upstream.body, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: {
|
|
||||||
"content-type":
|
|
||||||
upstream.headers.get("content-type") ?? "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { NextRequest } from "next/server";
|
|
||||||
import { CLOUD_BASE, FRONTEND_API_KEY } from "@/lib/cloud";
|
|
||||||
|
|
||||||
// GET /api/media — list/search media. Forwards query + cursor params.
|
|
||||||
export async function GET(req: NextRequest) {
|
|
||||||
const incoming = new URL(req.url);
|
|
||||||
const url = new URL("/api/media", CLOUD_BASE);
|
|
||||||
const query = incoming.searchParams.get("query");
|
|
||||||
const cursor = incoming.searchParams.get("cursor");
|
|
||||||
if (query) url.searchParams.set("query", query);
|
|
||||||
if (cursor) url.searchParams.set("cursor", cursor);
|
|
||||||
|
|
||||||
const upstream = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: { "x-api-key": FRONTEND_API_KEY },
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(upstream.body, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: {
|
|
||||||
"content-type":
|
|
||||||
upstream.headers.get("content-type") ?? "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// POST /api/media — upload. Forwards the multipart body as-is.
|
|
||||||
export async function POST(req: NextRequest) {
|
|
||||||
const upstream = await fetch(`${CLOUD_BASE}/api/media`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": req.headers.get("content-type") ?? "",
|
|
||||||
"x-api-key": FRONTEND_API_KEY,
|
|
||||||
},
|
|
||||||
body: req.body,
|
|
||||||
// @ts-expect-error - duplex is valid but missing from the lib types.
|
|
||||||
duplex: "half",
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(upstream.body, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: {
|
|
||||||
"content-type":
|
|
||||||
upstream.headers.get("content-type") ?? "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
|
// Editor for /collections/[handle]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets, and `../page.json` is the
|
||||||
|
// very file publishing writes back to.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/collections/[handle]" page={pageData} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Collection",
|
||||||
|
"description": "Shop the collection.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "collection-detail",
|
||||||
|
"props": {
|
||||||
|
"id": "collection-detail",
|
||||||
|
"collection": null,
|
||||||
|
"title": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import { getCollectionProductsPage } from '@/services/shopify/catalog';
|
||||||
|
import { truncate } from '@/lib/utils';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ handle: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { handle } = await params;
|
||||||
|
|
||||||
|
// No collection-only query exists, so ask for the smallest page of products
|
||||||
|
// and use just the collection node off it.
|
||||||
|
const { collection } = await getCollectionProductsPage(handle, {
|
||||||
|
first: 1,
|
||||||
|
}).catch(() => ({ collection: null }));
|
||||||
|
|
||||||
|
// The blocks fetch client-side and render their own empty state, so a failed
|
||||||
|
// lookup falls back to the page.json tags rather than a 500.
|
||||||
|
if (!collection) return pageMetadata(page);
|
||||||
|
|
||||||
|
return pageMetadata(page, {
|
||||||
|
title: collection.title,
|
||||||
|
description: collection.description
|
||||||
|
? truncate(collection.description, 160)
|
||||||
|
: `Shop the ${collection.title} collection.`,
|
||||||
|
image: collection.image?.url,
|
||||||
|
path: `/collections/${collection.handle}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
|
// Editor for /collections. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets, and `../page.json` is the
|
||||||
|
// very file publishing writes back to.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/collections" page={pageData} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Collections",
|
||||||
|
"description": "Browse every collection in the store.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "collections",
|
||||||
|
"props": {
|
||||||
|
"id": "collections-grid",
|
||||||
|
"title": "Our Collections",
|
||||||
|
"subtitle": "Discover our carefully crafted worlds"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export const metadata = pageMetadata(page, { path: '/collections' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { Editor, blocksPlugin, outlinePlugin } from "@reacteditor/core";
|
|
||||||
import createTailwindCdnPlugin from "@reacteditor/plugin-tailwind-cdn";
|
|
||||||
import { createShopifyPlugin } from "@reacteditor/plugin-shopify";
|
|
||||||
import { mediaPlugin } from "@reacteditor/plugin-media";
|
|
||||||
import { mediaAdapter } from "@/lib/adapters/media-adapter";
|
|
||||||
import { appConfig } from "@/editor.config";
|
|
||||||
import resolveRoute from "@/lib/resolve-route";
|
|
||||||
import schema from "@/app.schema.json";
|
|
||||||
import globals from "@/app.globals.json";
|
|
||||||
|
|
||||||
export default function EditorPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const segments = Array.isArray(params?.slug) ? (params.slug as string[]) : [];
|
|
||||||
const { key, path, params: routeParams } = resolveRoute(segments);
|
|
||||||
const { root, content } = (schema as any)[key] ?? {};
|
|
||||||
const data = { root, content, globals };
|
|
||||||
|
|
||||||
const plugins = useMemo(
|
|
||||||
() => [
|
|
||||||
blocksPlugin(),
|
|
||||||
outlinePlugin(),
|
|
||||||
mediaPlugin({
|
|
||||||
adapter: mediaAdapter,
|
|
||||||
}),
|
|
||||||
createTailwindCdnPlugin(),
|
|
||||||
createShopifyPlugin({
|
|
||||||
storeDomain: process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop",
|
|
||||||
publicAccessToken:
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handlePublish = async (nextData: any, route?: any) => {
|
|
||||||
const resolved = route ?? { key };
|
|
||||||
if (typeof window !== "undefined" && window.parent !== window) {
|
|
||||||
window.parent.postMessage(
|
|
||||||
{ type: "PUBLISH", data: { data: nextData, route: resolved } },
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen w-screen">
|
|
||||||
<Editor
|
|
||||||
config={appConfig as any}
|
|
||||||
data={data}
|
|
||||||
route={{ key, path, params: routeParams }}
|
|
||||||
plugins={plugins}
|
|
||||||
onPublish={handlePublish}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
|
// Editor for /. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets, and `../page.json` is the
|
||||||
|
// very file publishing writes back to.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/" page={pageData} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function Error({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
|
||||||
|
Something went wrong
|
||||||
|
</div>
|
||||||
|
<div className="border-l border-gray-300 h-6"></div>
|
||||||
|
<div className="text-sm text-gray-700 font-sans max-w-lg">
|
||||||
|
{error.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+218
-137
@@ -1,149 +1,230 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@import "tw-animate-css";
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Refined neutral modern palette */
|
||||||
|
--color-background: hsl(0 0% 100%);
|
||||||
|
--color-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
--color-card: hsl(0 0% 100%);
|
||||||
|
--color-card-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
|
/* Popover */
|
||||||
|
--color-popover: hsl(0 0% 100%);
|
||||||
|
--color-popover-foreground: hsl(240 10% 3.9%);
|
||||||
|
|
||||||
|
/* Primary - Deep neutral slate for professional look */
|
||||||
|
--color-primary: hsl(240 5.9% 10%);
|
||||||
|
--color-primary-foreground: hsl(0 0% 98%);
|
||||||
|
|
||||||
|
/* Secondary */
|
||||||
|
--color-secondary: hsl(240 4.8% 95.9%);
|
||||||
|
--color-secondary-foreground: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
|
/* Muted */
|
||||||
|
--color-muted: hsl(240 4.8% 95.9%);
|
||||||
|
--color-muted-foreground: hsl(240 3.8% 46.1%);
|
||||||
|
|
||||||
|
/* Accent - Subtle warm gray */
|
||||||
|
--color-accent: hsl(30 6.7% 95%);
|
||||||
|
--color-accent-foreground: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
|
/* Destructive */
|
||||||
|
--color-destructive: hsl(0 84.2% 60.2%);
|
||||||
|
--color-destructive-foreground: hsl(0 0% 98%);
|
||||||
|
|
||||||
|
/* Shop (accelerated checkout) purple */
|
||||||
|
--color-shop: #5a31f4;
|
||||||
|
|
||||||
|
/* Border */
|
||||||
|
--color-border: hsl(240 5.9% 90%);
|
||||||
|
--color-input: hsl(240 5.9% 90%);
|
||||||
|
--color-ring: hsl(240 5.9% 10%);
|
||||||
|
|
||||||
|
/* Radius - Modern larger corners */
|
||||||
|
--radius-sm: 0.5rem;
|
||||||
|
--radius-md: 0.75rem;
|
||||||
|
--radius-lg: 1rem;
|
||||||
|
--radius-xl: 1.25rem;
|
||||||
|
|
||||||
|
/* Typography — Geist Sans / Geist Mono */
|
||||||
|
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
|
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace;
|
||||||
|
--font-heading: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
|
--font-body: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
|
--font-poppins: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles for light, modern Shopify storefront */
|
||||||
|
/* Tailwind v4 defaults an unqualified `border` to currentColor. shadcn
|
||||||
|
components (e.g. Button's outline variant) rely on this base layer to pick up
|
||||||
|
the theme's border colour instead of the text colour. */
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-feature-settings:
|
||||||
|
'kern' 1,
|
||||||
|
'tnum' 1;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern card design for products and collections */
|
||||||
|
.card-modern {
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-modern:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.1);
|
||||||
|
border-color: hsl(240 5.9% 85%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heading styles — regular weight, slightly tightened like the Geist reference */
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
.font-heading {
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Swipeable rows (mobile galleries) without a visible scrollbar */
|
||||||
|
.no-scrollbar {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diagonal strike marking an unavailable product option (size pill, swatch) */
|
||||||
|
.option-unavailable {
|
||||||
|
background-image: linear-gradient(
|
||||||
|
to top right,
|
||||||
|
transparent calc(50% - 0.5px),
|
||||||
|
currentColor calc(50% - 0.5px),
|
||||||
|
currentColor calc(50% + 0.5px),
|
||||||
|
transparent calc(50% + 0.5px)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product description (HTML returned by the Storefront API) */
|
||||||
|
.product-description p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-description > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shop policy body (HTML returned by the Storefront API) */
|
||||||
|
.policy-body p,
|
||||||
|
.policy-body ul,
|
||||||
|
.policy-body ol {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-body ul,
|
||||||
|
.policy-body ol {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
list-style: revert;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-body li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-body h2,
|
||||||
|
.policy-body h3,
|
||||||
|
.policy-body h4 {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-body a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-body strong {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-body > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced button styles */
|
||||||
|
button,
|
||||||
|
.btn-modern {
|
||||||
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-modern:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow:
|
||||||
|
0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||||
|
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero section styles */
|
||||||
|
.hero-bg {
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Product gallery improvements */
|
||||||
|
.product-gallery-main {
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-gallery-main:hover {
|
||||||
|
box-shadow: 0 0 0 1px hsl(240 5.9% 80%) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clean price display */
|
||||||
|
.price-display {
|
||||||
|
font-feature-settings: 'tnum';
|
||||||
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--font-family-heading: var(--font-header);
|
--animate-rainbow: rainbow var(--speed, 2s) infinite linear;
|
||||||
--font-family-header: var(--font-header);
|
--color-color-5: var(--color-5);
|
||||||
--font-family-body: var(--font-body);
|
--color-color-4: var(--color-4);
|
||||||
|
--color-color-3: var(--color-3);
|
||||||
--color-background: var(--background);
|
--color-color-2: var(--color-2);
|
||||||
--color-foreground: var(--foreground);
|
--color-color-1: var(--color-1);
|
||||||
--color-card: var(--card);
|
@keyframes rainbow {
|
||||||
--color-card-foreground: var(--card-foreground);
|
0% {
|
||||||
--color-popover: var(--popover);
|
background-position: 0%;
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
}
|
||||||
--color-primary: var(--primary);
|
100% {
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
background-position: 200%;
|
||||||
--color-secondary: var(--secondary);
|
}
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) * 0.5);
|
|
||||||
--radius-md: calc(var(--radius) * 0.75);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) * 1.5);
|
|
||||||
|
|
||||||
--animate-marquee: marquee var(--duration) infinite linear;
|
|
||||||
--animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;
|
|
||||||
|
|
||||||
@keyframes marquee {
|
|
||||||
from { transform: translateX(0); }
|
|
||||||
to { transform: translateX(calc(-100% - var(--gap))); }
|
|
||||||
}
|
}
|
||||||
@keyframes marquee-vertical {
|
|
||||||
from { transform: translateY(0); }
|
|
||||||
to { transform: translateY(calc(-100% - var(--gap))); }
|
|
||||||
}
|
|
||||||
--font-heading: var(--font-sans);
|
|
||||||
--font-sans: 'Geist Variable', sans-serif;
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--radius-2xl: calc(var(--radius) * 2);
|
|
||||||
--radius-3xl: calc(var(--radius) * 3);
|
|
||||||
--radius-4xl: calc(var(--radius) * 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--color-1: oklch(66.2% 0.225 25.9);
|
||||||
--font-header: system-ui, -apple-system, sans-serif;
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--font-body: system-ui, -apple-system, sans-serif;
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
--background: oklch(1 0 0);
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
--foreground: oklch(0.145 0 0);
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.145 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
|
||||||
--primary: oklch(0.205 0 0);
|
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
|
||||||
--secondary: oklch(0.97 0 0);
|
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
|
||||||
--accent: oklch(0.97 0 0);
|
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
|
||||||
--destructive-foreground: #fafafa;
|
|
||||||
--border: oklch(0.922 0 0);
|
|
||||||
--input: oklch(0.922 0 0);
|
|
||||||
--ring: oklch(0.708 0 0);
|
|
||||||
--chart-1: oklch(0.87 0 0);
|
|
||||||
--chart-2: oklch(0.556 0 0);
|
|
||||||
--chart-3: oklch(0.439 0 0);
|
|
||||||
--chart-4: oklch(0.371 0 0);
|
|
||||||
--chart-5: oklch(0.269 0 0);
|
|
||||||
--sidebar: oklch(0.985 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* { @apply outline-ring/50; }
|
|
||||||
html, body { background-color: var(--background); color: var(--foreground); }
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body), "Apple Color Emoji", "Segoe UI Emoji";
|
|
||||||
margin: 0;
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
html {
|
|
||||||
@apply font-sans;}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--color-1: oklch(66.2% 0.225 25.9);
|
||||||
--foreground: oklch(0.985 0 0);
|
--color-2: oklch(60.4% 0.26 302);
|
||||||
--card: oklch(0.205 0 0);
|
--color-3: oklch(69.6% 0.165 251);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--color-4: oklch(80.2% 0.134 225);
|
||||||
--popover: oklch(0.205 0 0);
|
--color-5: oklch(90.7% 0.231 133);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.87 0 0);
|
|
||||||
--chart-2: oklch(0.556 0 0);
|
|
||||||
--chart-3: oklch(0.439 0 0);
|
|
||||||
--chart-4: oklch(0.371 0 0);
|
|
||||||
--chart-5: oklch(0.269 0 0);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
}
|
||||||
+47
-9
@@ -1,16 +1,54 @@
|
|||||||
import type { ReactNode } from "react";
|
import React from 'react';
|
||||||
import "./globals.css";
|
import type { Metadata } from 'next';
|
||||||
import { Providers } from "./providers";
|
import './globals.css';
|
||||||
|
import { Geist, Geist_Mono } from 'next/font/google';
|
||||||
|
import { site } from '@/config/site';
|
||||||
|
|
||||||
export const metadata = {
|
const geist = Geist({
|
||||||
title: "Shopify Storefront",
|
subsets: ['latin'],
|
||||||
|
variable: '--font-geist-sans',
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-geist-mono',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-page tags come from each page.json's root props via pageMetadata(); this
|
||||||
|
// is the fallback for routes that don't set their own.
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(site.url),
|
||||||
|
title: {
|
||||||
|
default: site.storeName,
|
||||||
|
template: `%s — ${site.storeName}`,
|
||||||
|
},
|
||||||
|
description: site.description,
|
||||||
|
openGraph: {
|
||||||
|
type: 'website',
|
||||||
|
siteName: site.storeName,
|
||||||
|
title: site.storeName,
|
||||||
|
description: site.description,
|
||||||
|
url: '/',
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
title: site.storeName,
|
||||||
|
description: site.description,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${geist.variable} ${geistMono.variable}`}>
|
||||||
<body>
|
{/* Header, footer and the store assistant are editable blocks now, so
|
||||||
<Providers>{children}</Providers>
|
each page.json carries its own chrome rather than the layout doing
|
||||||
|
it. Keeps what the editor shows identical to what the route ships. */}
|
||||||
|
<body className="font-body antialiased bg-background text-foreground m-0 p-0 flex min-h-screen flex-col">
|
||||||
|
<div className="flex-1">{children}</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="text-lg font-medium text-black font-sans whitespace-nowrap">
|
||||||
|
404
|
||||||
|
</div>
|
||||||
|
<div className="border-l border-gray-300 h-6"></div>
|
||||||
|
<div className="text-sm text-gray-700 font-sans max-w-lg">
|
||||||
|
This page could not be found.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Shop",
|
||||||
|
"description": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "products",
|
||||||
|
"props": {
|
||||||
|
"id": "home-products",
|
||||||
|
"title": "Shopify Hydrogen Storefront",
|
||||||
|
"subtitle": "An agent-friendly Shopify storefront built with Next.js and Hydrogen.",
|
||||||
|
"collection": null,
|
||||||
|
"limit": 12
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export const metadata = pageMetadata(page, { path: '/' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
|
// Editor for /policies/[handle]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets, and `../page.json` is the
|
||||||
|
// very file publishing writes back to.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/policies/[handle]" page={pageData} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Policy",
|
||||||
|
"description": "Store policies.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "policy",
|
||||||
|
"props": {
|
||||||
|
"id": "policy-body",
|
||||||
|
"handle": "",
|
||||||
|
"title": "",
|
||||||
|
"notFoundMessage": "This policy has not been published yet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import { getShopPolicy, POLICY_HANDLES } from '@/hooks/use-shopify-policies';
|
||||||
|
import { site } from '@/config/site';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return POLICY_HANDLES.map((handle) => ({ handle }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ handle: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { handle } = await params;
|
||||||
|
const policy = await getShopPolicy(handle).catch(() => null);
|
||||||
|
|
||||||
|
if (!policy) return pageMetadata(page);
|
||||||
|
|
||||||
|
return pageMetadata(page, {
|
||||||
|
title: policy.title,
|
||||||
|
description: `Read the ${policy.title.toLowerCase()} for ${site.storeName}.`,
|
||||||
|
path: `/policies/${policy.handle}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ handle: string }>;
|
||||||
|
}) {
|
||||||
|
const { handle } = await params;
|
||||||
|
|
||||||
|
// Keep the 404 for handles Shopify doesn't know, rather than rendering the
|
||||||
|
// policy block's "not published yet" state for a genuinely bad URL.
|
||||||
|
if (!POLICY_HANDLES.includes(handle as (typeof POLICY_HANDLES)[number])) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
|
// Editor for /products/[handle]. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets, and `../page.json` is the
|
||||||
|
// very file publishing writes back to.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/products/[handle]" page={pageData} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Product",
|
||||||
|
"description": "Shop this product.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "product-detail",
|
||||||
|
"props": {
|
||||||
|
"id": "product-detail",
|
||||||
|
"product": null,
|
||||||
|
"addToCartLabel": "Add to Cart"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "product-recommendations",
|
||||||
|
"props": {
|
||||||
|
"id": "product-recommendations",
|
||||||
|
"title": "You May Also Like",
|
||||||
|
"product": null,
|
||||||
|
"limit": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import { getProduct } from '@/services/shopify/catalog';
|
||||||
|
import { truncate } from '@/lib/utils';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export async function generateMetadata({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ handle: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { handle } = await params;
|
||||||
|
|
||||||
|
// The blocks fetch client-side and render their own empty state, so a failed
|
||||||
|
// lookup falls back to the page.json tags rather than a 500.
|
||||||
|
const product = await getProduct(handle).catch(() => null);
|
||||||
|
if (!product) return pageMetadata(page);
|
||||||
|
|
||||||
|
return pageMetadata(page, {
|
||||||
|
title: product.title,
|
||||||
|
description: product.description
|
||||||
|
? truncate(product.description, 160)
|
||||||
|
: undefined,
|
||||||
|
image: product.images.edges[0]?.node.url,
|
||||||
|
path: `/products/${product.handle}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
|
||||||
import { ShopifyProvider } from "@/contexts/shopify-context";
|
|
||||||
|
|
||||||
const SHOPIFY_DOMAIN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_DOMAIN ?? "mock.shop";
|
|
||||||
const STOREFRONT_TOKEN =
|
|
||||||
process.env.NEXT_PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN ?? "";
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ShopifyProvider domain={SHOPIFY_DOMAIN} token={STOREFRONT_TOKEN}>
|
|
||||||
{children}
|
|
||||||
</ShopifyProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import PageEditor from '@/components/page-editor';
|
||||||
|
import pageData from '../page.json';
|
||||||
|
|
||||||
|
// Editor for /search. Sitting under the route it edits means the preview
|
||||||
|
// resolves the same params the public page gets, and `../page.json` is the
|
||||||
|
// very file publishing writes back to.
|
||||||
|
export default function EditorPage() {
|
||||||
|
return <PageEditor routeKey="/search" page={pageData} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"root": {
|
||||||
|
"props": {
|
||||||
|
"title": "Search",
|
||||||
|
"description": "Search the catalogue by product name, description or type.",
|
||||||
|
"ogImage": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"props": {
|
||||||
|
"id": "header"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "search-results",
|
||||||
|
"props": {
|
||||||
|
"id": "search-results",
|
||||||
|
"title": "Search"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "footer",
|
||||||
|
"props": {
|
||||||
|
"id": "footer"
|
||||||
|
},
|
||||||
|
"synced": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "store-assistant",
|
||||||
|
"props": {
|
||||||
|
"id": "store-assistant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
import page from './page.json';
|
||||||
|
|
||||||
|
export const metadata = pageMetadata(page, { path: '/search' });
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
// The search-results block reads useSearchParams, which needs a Suspense
|
||||||
|
// boundary to prerender this route.
|
||||||
|
<Suspense fallback={<div className="py-10" />}>
|
||||||
|
<PageRender page={page} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import PageRender from '@/components/page-render';
|
||||||
|
import { pageMetadata } from '@/lib/page-metadata';
|
||||||
|
// Alias of /collections — same page.json, so editing that route updates both.
|
||||||
|
import page from '../../collections/page.json';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
...pageMetadata(page),
|
||||||
|
// Point crawlers at the canonical listing.
|
||||||
|
alternates: { canonical: '/collections' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <PageRender page={page} />;
|
||||||
|
}
|
||||||
+4
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
"style": "base-nova",
|
"style": "new-york",
|
||||||
"rsc": true,
|
"rsc": true,
|
||||||
"tsx": true,
|
"tsx": true,
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
"prefix": ""
|
"prefix": ""
|
||||||
},
|
},
|
||||||
"iconLibrary": "lucide",
|
"iconLibrary": "lucide",
|
||||||
"rtl": false,
|
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "@/components",
|
"components": "@/components",
|
||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
@@ -19,7 +18,7 @@
|
|||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/hooks"
|
||||||
},
|
},
|
||||||
"menuColor": "default",
|
"registries": {
|
||||||
"menuAccent": "subtle",
|
"@magicui": "https://magicui.design/r/{name}"
|
||||||
"registries": {}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Typography, type TypographyVariant } from "@/components/Typography";
|
|
||||||
|
|
||||||
export type HeadingSize = "sm" | "md" | "lg" | "xl";
|
|
||||||
export type HeadingAlign = "left" | "center";
|
|
||||||
export type HeadingTone = "default" | "light";
|
|
||||||
|
|
||||||
type SizeMap = {
|
|
||||||
title: TypographyVariant;
|
|
||||||
subtitle: TypographyVariant;
|
|
||||||
taglineGap: string;
|
|
||||||
subtitleGap: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeMap: Record<HeadingSize, SizeMap> = {
|
|
||||||
sm: {
|
|
||||||
title: "h4",
|
|
||||||
subtitle: "subtitle2",
|
|
||||||
taglineGap: "mb-2",
|
|
||||||
subtitleGap: "mt-2",
|
|
||||||
},
|
|
||||||
md: {
|
|
||||||
title: "h3",
|
|
||||||
subtitle: "subtitle1",
|
|
||||||
taglineGap: "mb-3",
|
|
||||||
subtitleGap: "mt-3",
|
|
||||||
},
|
|
||||||
lg: {
|
|
||||||
title: "h2",
|
|
||||||
subtitle: "subtitle1",
|
|
||||||
taglineGap: "mb-3",
|
|
||||||
subtitleGap: "mt-3",
|
|
||||||
},
|
|
||||||
xl: {
|
|
||||||
title: "h1",
|
|
||||||
subtitle: "subtitle1",
|
|
||||||
taglineGap: "mb-4",
|
|
||||||
subtitleGap: "mt-4",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const alignClasses: Record<HeadingAlign, string> = {
|
|
||||||
left: "items-start text-left",
|
|
||||||
center: "items-center text-center",
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HeadingProps = {
|
|
||||||
tagline?: React.ReactNode;
|
|
||||||
title?: React.ReactNode;
|
|
||||||
subtitle?: React.ReactNode;
|
|
||||||
size?: HeadingSize;
|
|
||||||
align?: HeadingAlign;
|
|
||||||
tone?: HeadingTone;
|
|
||||||
className?: string;
|
|
||||||
titleClassName?: string;
|
|
||||||
subtitleClassName?: string;
|
|
||||||
taglineClassName?: string;
|
|
||||||
maxWidth?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Heading({
|
|
||||||
tagline,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
size = "lg",
|
|
||||||
align = "left",
|
|
||||||
tone = "default",
|
|
||||||
className,
|
|
||||||
titleClassName,
|
|
||||||
subtitleClassName,
|
|
||||||
taglineClassName,
|
|
||||||
maxWidth,
|
|
||||||
}: HeadingProps) {
|
|
||||||
if (!tagline && !title && !subtitle) return null;
|
|
||||||
const map = sizeMap[size];
|
|
||||||
const isLight = tone === "light";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn("flex flex-col", alignClasses[align], maxWidth, className)}>
|
|
||||||
{tagline ? (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
className={cn(
|
|
||||||
map.taglineGap,
|
|
||||||
isLight && "text-background/70",
|
|
||||||
taglineClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tagline}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{title ? (
|
|
||||||
<Typography
|
|
||||||
variant={map.title}
|
|
||||||
className={cn(isLight && "text-background", titleClassName)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{subtitle ? (
|
|
||||||
<Typography
|
|
||||||
variant={map.subtitle}
|
|
||||||
className={cn(
|
|
||||||
map.subtitleGap,
|
|
||||||
isLight && "text-background/70",
|
|
||||||
subtitleClassName,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subtitle}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Heading;
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
|
|
||||||
export type ThemeProps = {
|
|
||||||
headerFont?: string;
|
|
||||||
headerFontWeight?: string;
|
|
||||||
bodyFont?: string;
|
|
||||||
primaryColor?: string;
|
|
||||||
primaryForegroundColor?: string;
|
|
||||||
secondaryColor?: string;
|
|
||||||
accentColor?: string;
|
|
||||||
bgColor?: string;
|
|
||||||
fgColor?: string;
|
|
||||||
mutedColor?: string;
|
|
||||||
mutedForegroundColor?: string;
|
|
||||||
borderColor?: string;
|
|
||||||
radius?: "none" | "sm" | "md" | "lg" | "xl";
|
|
||||||
shadow?: "none" | "sm" | "md" | "lg" | "xl";
|
|
||||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "full";
|
|
||||||
};
|
|
||||||
|
|
||||||
const radiusMap: Record<NonNullable<ThemeProps["radius"]>, string> = {
|
|
||||||
none: "0px",
|
|
||||||
sm: "0.25rem",
|
|
||||||
md: "0.5rem",
|
|
||||||
lg: "0.75rem",
|
|
||||||
xl: "1rem",
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxWidthMap: Record<NonNullable<ThemeProps["maxWidth"]>, string> = {
|
|
||||||
sm: "64rem",
|
|
||||||
md: "72rem",
|
|
||||||
lg: "80rem",
|
|
||||||
xl: "96rem",
|
|
||||||
full: "100%",
|
|
||||||
};
|
|
||||||
|
|
||||||
const shadowMap: Record<NonNullable<ThemeProps["shadow"]>, string> = {
|
|
||||||
none: "0 0 #0000",
|
|
||||||
sm: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
|
||||||
md: "0 4px 6px -1px rgb(0 0 0 / 0.10), 0 2px 4px -2px rgb(0 0 0 / 0.10)",
|
|
||||||
lg: "0 10px 15px -3px rgb(0 0 0 / 0.10), 0 4px 6px -4px rgb(0 0 0 / 0.10)",
|
|
||||||
xl: "0 20px 25px -5px rgb(0 0 0 / 0.10), 0 8px 10px -6px rgb(0 0 0 / 0.10)",
|
|
||||||
};
|
|
||||||
|
|
||||||
function googleFontsHref(
|
|
||||||
headerFont?: string,
|
|
||||||
bodyFont?: string,
|
|
||||||
headerFontWeight?: string,
|
|
||||||
): string | null {
|
|
||||||
const valid = (f?: string): f is string => !!f && f !== "system-ui";
|
|
||||||
const families: string[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
const headerWeight = headerFontWeight || "400";
|
|
||||||
const bodyWeights = "400;500;600;700";
|
|
||||||
|
|
||||||
if (valid(headerFont)) {
|
|
||||||
seen.add(headerFont);
|
|
||||||
// Header font uses the configured weight only — avoids HTTP 400 from
|
|
||||||
// Google Fonts when a single-weight family (e.g. Archivo Black) is paired
|
|
||||||
// with a multi-weight default request.
|
|
||||||
families.push(
|
|
||||||
`family=${encodeURIComponent(headerFont)}:wght@${headerWeight}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (valid(bodyFont) && !seen.has(bodyFont)) {
|
|
||||||
seen.add(bodyFont);
|
|
||||||
families.push(
|
|
||||||
`family=${encodeURIComponent(bodyFont)}:wght@${bodyWeights}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (families.length === 0) return null;
|
|
||||||
return `https://fonts.googleapis.com/css2?${families.join("&")}&display=swap`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ThemeProvider({
|
|
||||||
headerFont,
|
|
||||||
headerFontWeight,
|
|
||||||
bodyFont,
|
|
||||||
primaryColor,
|
|
||||||
primaryForegroundColor,
|
|
||||||
secondaryColor,
|
|
||||||
accentColor,
|
|
||||||
bgColor,
|
|
||||||
fgColor,
|
|
||||||
mutedColor,
|
|
||||||
mutedForegroundColor,
|
|
||||||
borderColor,
|
|
||||||
radius,
|
|
||||||
shadow,
|
|
||||||
maxWidth,
|
|
||||||
children,
|
|
||||||
}: ThemeProps & { children?: React.ReactNode }) {
|
|
||||||
// Recompute CSS-variable map only when a relevant prop changes.
|
|
||||||
const cssVars = useMemo<Record<string, string>>(() => {
|
|
||||||
const vars: Record<string, string> = {};
|
|
||||||
if (primaryColor) vars["--primary"] = primaryColor;
|
|
||||||
if (primaryForegroundColor) vars["--primary-foreground"] = primaryForegroundColor;
|
|
||||||
if (secondaryColor) vars["--secondary"] = secondaryColor;
|
|
||||||
if (accentColor) vars["--accent"] = accentColor;
|
|
||||||
if (bgColor) vars["--background"] = bgColor;
|
|
||||||
if (fgColor) vars["--foreground"] = fgColor;
|
|
||||||
if (mutedColor) vars["--muted"] = mutedColor;
|
|
||||||
if (mutedForegroundColor) vars["--muted-foreground"] = mutedForegroundColor;
|
|
||||||
if (borderColor) vars["--border"] = borderColor;
|
|
||||||
if (radius) vars["--radius"] = radiusMap[radius];
|
|
||||||
if (shadow) vars["--shadow"] = shadowMap[shadow];
|
|
||||||
if (maxWidth) vars["--container-max-width"] = maxWidthMap[maxWidth];
|
|
||||||
if (headerFont) vars["--font-header"] = `"${headerFont}", system-ui, sans-serif`;
|
|
||||||
if (bodyFont) vars["--font-body"] = `"${bodyFont}", system-ui, sans-serif`;
|
|
||||||
if (headerFontWeight) vars["--font-weight-header"] = headerFontWeight;
|
|
||||||
return vars;
|
|
||||||
}, [
|
|
||||||
headerFont,
|
|
||||||
headerFontWeight,
|
|
||||||
bodyFont,
|
|
||||||
primaryColor,
|
|
||||||
primaryForegroundColor,
|
|
||||||
secondaryColor,
|
|
||||||
accentColor,
|
|
||||||
bgColor,
|
|
||||||
fgColor,
|
|
||||||
mutedColor,
|
|
||||||
mutedForegroundColor,
|
|
||||||
borderColor,
|
|
||||||
radius,
|
|
||||||
shadow,
|
|
||||||
maxWidth,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Imperatively push every CSS var onto :root inside the host document
|
|
||||||
// (which is the iframe's document for the editor preview, and the page
|
|
||||||
// <html> for the published render). This guarantees descendants pick up
|
|
||||||
// updates even if React's style-prop diffing missed something or the
|
|
||||||
// base-layer rules need access via :root inheritance.
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
const doc =
|
|
||||||
rootRef.current?.ownerDocument ??
|
|
||||||
(typeof document !== "undefined" ? document : null);
|
|
||||||
if (!doc) return;
|
|
||||||
const target = doc.documentElement;
|
|
||||||
const previous: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(cssVars)) {
|
|
||||||
previous[key] = target.style.getPropertyValue(key);
|
|
||||||
target.style.setProperty(key, value);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
// Restore prior values so unmount doesn't leak our overrides.
|
|
||||||
for (const [key, value] of Object.entries(previous)) {
|
|
||||||
if (value) target.style.setProperty(key, value);
|
|
||||||
else target.style.removeProperty(key);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [cssVars]);
|
|
||||||
|
|
||||||
const fontsHref = useMemo(
|
|
||||||
() => googleFontsHref(headerFont, bodyFont, headerFontWeight),
|
|
||||||
[headerFont, bodyFont, headerFontWeight],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Plain CSS rules — applied directly, no Tailwind CDN runtime needed.
|
|
||||||
// Body font is set on `body` once and inherited by descendants (span, a,
|
|
||||||
// p, li, etc. don't need explicit rules — applying one to `span/a` would
|
|
||||||
// break heading children, anchors inside headings, and any element using
|
|
||||||
// `as="span"` to render a heading variant).
|
|
||||||
// Form controls have user-agent defaults, so they need an explicit override.
|
|
||||||
// Tailwind preflight resets h1..h6 to font-family: inherit, so we restore
|
|
||||||
// the heading font + weight here using the per-page CSS vars.
|
|
||||||
const css = `
|
|
||||||
body {
|
|
||||||
font-family: var(--font-body), system-ui, -apple-system, sans-serif;
|
|
||||||
}
|
|
||||||
button, input, textarea, select {
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-family: var(--font-header), system-ui, -apple-system, sans-serif;
|
|
||||||
font-weight: var(--font-weight-header, 600);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Tailwind theme directives — only useful if/when the CDN compiles
|
|
||||||
// font-heading / font-body utilities. The plain CSS above handles the
|
|
||||||
// common case so headers always render with the right font.
|
|
||||||
const tailwindCss = `
|
|
||||||
@theme {
|
|
||||||
--font-family-heading: var(--font-header), system-ui, -apple-system, sans-serif;
|
|
||||||
--font-family-body: var(--font-body), system-ui, -apple-system, sans-serif;
|
|
||||||
--radius-sm: calc(var(--radius) * 0.5);
|
|
||||||
--radius-md: calc(var(--radius) * 0.75);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) * 1.5);
|
|
||||||
--radius-2xl: calc(var(--radius) * 2);
|
|
||||||
--radius-3xl: calc(var(--radius) * 3);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{fontsHref ? <link rel="stylesheet" href={fontsHref} /> : null}
|
|
||||||
<style dangerouslySetInnerHTML={{ __html: css }} />
|
|
||||||
<style type="text/tailwindcss" dangerouslySetInnerHTML={{ __html: tailwindCss }} />
|
|
||||||
<div
|
|
||||||
ref={rootRef}
|
|
||||||
data-theme-root
|
|
||||||
style={cssVars as React.CSSProperties}
|
|
||||||
className="flex min-h-screen flex-col bg-background text-foreground"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type TypographyVariant =
|
|
||||||
| "h1"
|
|
||||||
| "h2"
|
|
||||||
| "h3"
|
|
||||||
| "h4"
|
|
||||||
| "h5"
|
|
||||||
| "h6"
|
|
||||||
| "subtitle1"
|
|
||||||
| "subtitle2"
|
|
||||||
| "body1"
|
|
||||||
| "body2"
|
|
||||||
| "caption";
|
|
||||||
|
|
||||||
const sizeClasses: Record<TypographyVariant, string> = {
|
|
||||||
h1: "text-5xl md:text-6xl lg:text-7xl tracking-tight leading-[1.05]",
|
|
||||||
h2: "text-4xl md:text-5xl tracking-tight leading-[1.1]",
|
|
||||||
h3: "text-3xl md:text-4xl tracking-tight leading-tight",
|
|
||||||
h4: "text-2xl md:text-3xl tracking-tight leading-snug",
|
|
||||||
h5: "text-xl md:text-2xl leading-snug",
|
|
||||||
h6: "text-lg md:text-xl leading-snug",
|
|
||||||
subtitle1: "text-lg md:text-xl leading-relaxed text-muted-foreground",
|
|
||||||
subtitle2: "text-base md:text-lg leading-relaxed text-muted-foreground",
|
|
||||||
body1: "text-lg leading-relaxed",
|
|
||||||
body2: "text-base leading-relaxed",
|
|
||||||
caption: "text-xs font-bold uppercase tracking-[0.2em] text-muted-foreground",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inline-style fallbacks so headings size correctly even when Tailwind
|
|
||||||
// preflight resets <h1>..<h6> to font-size: inherit and the iframe CDN
|
|
||||||
// hasn't compiled utility classes yet. The Tailwind classes above still
|
|
||||||
// apply on top once available (responsive breakpoints, leading, etc.).
|
|
||||||
const sizeStyles: Record<TypographyVariant, React.CSSProperties> = {
|
|
||||||
h1: { fontSize: "clamp(2.5rem, 6vw, 4.5rem)", lineHeight: 1.05, letterSpacing: "-0.02em" },
|
|
||||||
h2: { fontSize: "clamp(2rem, 4vw, 3rem)", lineHeight: 1.1, letterSpacing: "-0.02em" },
|
|
||||||
h3: { fontSize: "clamp(1.75rem, 3.5vw, 2.25rem)", lineHeight: 1.15, letterSpacing: "-0.015em" },
|
|
||||||
h4: { fontSize: "clamp(1.5rem, 3vw, 1.875rem)", lineHeight: 1.2, letterSpacing: "-0.01em" },
|
|
||||||
h5: { fontSize: "1.5rem", lineHeight: 1.25 },
|
|
||||||
h6: { fontSize: "1.25rem", lineHeight: 1.3 },
|
|
||||||
subtitle1: { fontSize: "1.125rem", lineHeight: 1.6 },
|
|
||||||
subtitle2: { fontSize: "1rem", lineHeight: 1.6 },
|
|
||||||
body1: { fontSize: "1.125rem", lineHeight: 1.6 },
|
|
||||||
body2: { fontSize: "1rem", lineHeight: 1.6 },
|
|
||||||
caption: { fontSize: "0.75rem", lineHeight: 1.5, fontWeight: 700, letterSpacing: "0.2em", textTransform: "uppercase" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultTag: Record<TypographyVariant, keyof JSX.IntrinsicElements> = {
|
|
||||||
h1: "h1",
|
|
||||||
h2: "h2",
|
|
||||||
h3: "h3",
|
|
||||||
h4: "h4",
|
|
||||||
h5: "h5",
|
|
||||||
h6: "h6",
|
|
||||||
subtitle1: "p",
|
|
||||||
subtitle2: "p",
|
|
||||||
body1: "p",
|
|
||||||
body2: "p",
|
|
||||||
caption: "p",
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props<C extends keyof JSX.IntrinsicElements = "p"> = {
|
|
||||||
variant: TypographyVariant;
|
|
||||||
as?: C;
|
|
||||||
className?: string;
|
|
||||||
style?: React.CSSProperties;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
} & Omit<React.ComponentPropsWithoutRef<C>, "className" | "children" | "style">;
|
|
||||||
|
|
||||||
export function Typography<C extends keyof JSX.IntrinsicElements = "p">({
|
|
||||||
variant,
|
|
||||||
as,
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
children,
|
|
||||||
...rest
|
|
||||||
}: Props<C>) {
|
|
||||||
const Tag = (as ?? defaultTag[variant]) as keyof JSX.IntrinsicElements;
|
|
||||||
const isHeading = variant.startsWith("h");
|
|
||||||
const fontClass = isHeading ? "font-heading" : "font-body";
|
|
||||||
|
|
||||||
// Apply the heading font + weight inline so the styling holds even when
|
|
||||||
// the rendered element isn't h1–h6 (e.g. <span> via the `as` prop). The
|
|
||||||
// ThemeProvider's element-scoped CSS rule only matches real h-tags, and
|
|
||||||
// the `font-heading` Tailwind utility can't be relied on in CDN mode.
|
|
||||||
const fontStyles: React.CSSProperties = isHeading
|
|
||||||
? {
|
|
||||||
fontFamily: "var(--font-header), system-ui, sans-serif",
|
|
||||||
fontWeight: "var(--font-weight-header, 600)",
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
return React.createElement(
|
|
||||||
Tag,
|
|
||||||
{
|
|
||||||
className: cn(fontClass, sizeClasses[variant], className),
|
|
||||||
style: { ...fontStyles, ...sizeStyles[variant], ...style },
|
|
||||||
...rest,
|
|
||||||
},
|
|
||||||
children,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Typography;
|
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from "@/components/ui/hover-card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { FileUIPart, SourceDocumentUIPart } from "ai";
|
||||||
|
import {
|
||||||
|
FileTextIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
ImageIcon,
|
||||||
|
Music2Icon,
|
||||||
|
PaperclipIcon,
|
||||||
|
VideoIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||||
|
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentData =
|
||||||
|
| (FileUIPart & { id: string })
|
||||||
|
| (SourceDocumentUIPart & { id: string });
|
||||||
|
|
||||||
|
export type AttachmentMediaCategory =
|
||||||
|
| "image"
|
||||||
|
| "video"
|
||||||
|
| "audio"
|
||||||
|
| "document"
|
||||||
|
| "source"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type AttachmentVariant = "grid" | "inline" | "list";
|
||||||
|
|
||||||
|
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
|
||||||
|
audio: Music2Icon,
|
||||||
|
document: FileTextIcon,
|
||||||
|
image: ImageIcon,
|
||||||
|
source: GlobeIcon,
|
||||||
|
unknown: PaperclipIcon,
|
||||||
|
video: VideoIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const getMediaCategory = (
|
||||||
|
data: AttachmentData
|
||||||
|
): AttachmentMediaCategory => {
|
||||||
|
if (data.type === "source-document") {
|
||||||
|
return "source";
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType = data.mediaType ?? "";
|
||||||
|
|
||||||
|
if (mediaType.startsWith("image/")) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith("video/")) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith("audio/")) {
|
||||||
|
return "audio";
|
||||||
|
}
|
||||||
|
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
|
||||||
|
return "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAttachmentLabel = (data: AttachmentData): string => {
|
||||||
|
if (data.type === "source-document") {
|
||||||
|
return data.title || data.filename || "Source";
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = getMediaCategory(data);
|
||||||
|
return data.filename || (category === "image" ? "Image" : "Attachment");
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAttachmentImage = (
|
||||||
|
url: string,
|
||||||
|
filename: string | undefined,
|
||||||
|
isGrid: boolean
|
||||||
|
) =>
|
||||||
|
isGrid ? (
|
||||||
|
<img
|
||||||
|
alt={filename || "Image"}
|
||||||
|
className="size-full object-cover"
|
||||||
|
height={96}
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
alt={filename || "Image"}
|
||||||
|
className="size-full rounded object-cover"
|
||||||
|
height={20}
|
||||||
|
src={url}
|
||||||
|
width={20}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contexts
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface AttachmentsContextValue {
|
||||||
|
variant: AttachmentVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
|
||||||
|
|
||||||
|
interface AttachmentContextValue {
|
||||||
|
data: AttachmentData;
|
||||||
|
mediaCategory: AttachmentMediaCategory;
|
||||||
|
onRemove?: () => void;
|
||||||
|
variant: AttachmentVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Hooks
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const useAttachmentsContext = () =>
|
||||||
|
useContext(AttachmentsContext) ?? { variant: "grid" as const };
|
||||||
|
|
||||||
|
export const useAttachmentContext = () => {
|
||||||
|
const ctx = useContext(AttachmentContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("Attachment components must be used within <Attachment>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attachments - Container
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
variant?: AttachmentVariant;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Attachments = ({
|
||||||
|
variant = "grid",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentsProps) => {
|
||||||
|
const contextValue = useMemo(() => ({ variant }), [variant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttachmentsContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-start",
|
||||||
|
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
|
||||||
|
variant === "grid" && "ml-auto w-fit",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AttachmentsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Attachment - Item
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
data: AttachmentData;
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Attachment = ({
|
||||||
|
data,
|
||||||
|
onRemove,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentProps) => {
|
||||||
|
const { variant } = useAttachmentsContext();
|
||||||
|
const mediaCategory = getMediaCategory(data);
|
||||||
|
|
||||||
|
const contextValue = useMemo<AttachmentContextValue>(
|
||||||
|
() => ({ data, mediaCategory, onRemove, variant }),
|
||||||
|
[data, mediaCategory, onRemove, variant]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AttachmentContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative",
|
||||||
|
variant === "grid" && "size-24 overflow-hidden rounded-lg",
|
||||||
|
variant === "inline" && [
|
||||||
|
"flex h-8 cursor-pointer select-none items-center gap-1.5",
|
||||||
|
"rounded-md border border-border px-1.5",
|
||||||
|
"font-medium text-sm transition-all",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
],
|
||||||
|
variant === "list" && [
|
||||||
|
"flex w-full items-center gap-3 rounded-lg border p-3",
|
||||||
|
"hover:bg-accent/50",
|
||||||
|
],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AttachmentContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentPreview - Media preview
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
fallbackIcon?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttachmentPreview = ({
|
||||||
|
fallbackIcon,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AttachmentPreviewProps) => {
|
||||||
|
const { data, mediaCategory, variant } = useAttachmentContext();
|
||||||
|
|
||||||
|
const iconSize = variant === "inline" ? "size-3" : "size-4";
|
||||||
|
|
||||||
|
const renderIcon = (Icon: typeof ImageIcon) => (
|
||||||
|
<Icon className={cn(iconSize, "text-muted-foreground")} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (mediaCategory === "image" && data.type === "file" && data.url) {
|
||||||
|
return renderAttachmentImage(data.url, data.filename, variant === "grid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaCategory === "video" && data.type === "file" && data.url) {
|
||||||
|
return <video className="size-full object-cover" muted src={data.url} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = mediaCategoryIcons[mediaCategory];
|
||||||
|
return fallbackIcon ?? renderIcon(Icon);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 items-center justify-center overflow-hidden",
|
||||||
|
variant === "grid" && "size-full bg-muted",
|
||||||
|
variant === "inline" && "size-5 rounded bg-background",
|
||||||
|
variant === "list" && "size-12 rounded bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentInfo - Name and type display
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
showMediaType?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttachmentInfo = ({
|
||||||
|
showMediaType = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AttachmentInfoProps) => {
|
||||||
|
const { data, variant } = useAttachmentContext();
|
||||||
|
const label = getAttachmentLabel(data);
|
||||||
|
|
||||||
|
if (variant === "grid") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("min-w-0 flex-1", className)} {...props}>
|
||||||
|
<span className="block truncate">{label}</span>
|
||||||
|
{showMediaType && data.mediaType && (
|
||||||
|
<span className="block truncate text-muted-foreground text-xs">
|
||||||
|
{data.mediaType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentRemove - Remove button
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AttachmentRemove = ({
|
||||||
|
label = "Remove",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentRemoveProps) => {
|
||||||
|
const { onRemove, variant } = useAttachmentContext();
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove?.();
|
||||||
|
},
|
||||||
|
[onRemove]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onRemove) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
variant === "grid" && [
|
||||||
|
"absolute top-2 right-2 size-6 rounded-full p-0",
|
||||||
|
"bg-background/80 backdrop-blur-sm",
|
||||||
|
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||||
|
"hover:bg-background",
|
||||||
|
"[&>svg]:size-3",
|
||||||
|
],
|
||||||
|
variant === "inline" && [
|
||||||
|
"size-5 rounded p-0",
|
||||||
|
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||||
|
"[&>svg]:size-2.5",
|
||||||
|
],
|
||||||
|
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <XIcon />}
|
||||||
|
<span className="sr-only">{label}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentHoverCard - Hover preview
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||||
|
|
||||||
|
export const AttachmentHoverCard = ({
|
||||||
|
openDelay = 0,
|
||||||
|
closeDelay = 0,
|
||||||
|
...props
|
||||||
|
}: AttachmentHoverCardProps) => (
|
||||||
|
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AttachmentHoverCardTriggerProps = ComponentProps<
|
||||||
|
typeof HoverCardTrigger
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const AttachmentHoverCardTrigger = (
|
||||||
|
props: AttachmentHoverCardTriggerProps
|
||||||
|
) => <HoverCardTrigger {...props} />;
|
||||||
|
|
||||||
|
export type AttachmentHoverCardContentProps = ComponentProps<
|
||||||
|
typeof HoverCardContent
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const AttachmentHoverCardContent = ({
|
||||||
|
align = "start",
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AttachmentHoverCardContentProps) => (
|
||||||
|
<HoverCardContent
|
||||||
|
align={align}
|
||||||
|
className={cn("w-auto p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AttachmentEmpty - Empty state
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const AttachmentEmpty = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AttachmentEmptyProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center p-4 text-muted-foreground text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? "No attachments"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||||
|
|
||||||
|
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||||
|
|
||||||
|
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||||
|
<StickToBottom
|
||||||
|
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||||
|
initial="smooth"
|
||||||
|
resize="smooth"
|
||||||
|
role="log"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationContentProps = ComponentProps<
|
||||||
|
typeof StickToBottom.Content
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ConversationContent = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ConversationContentProps) => (
|
||||||
|
<StickToBottom.Content
|
||||||
|
className={cn("flex flex-col gap-8 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConversationEmptyState = ({
|
||||||
|
className,
|
||||||
|
title = "No messages yet",
|
||||||
|
description = "Start a conversation to see messages here",
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ConversationEmptyStateProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-medium text-sm">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const ConversationScrollButton = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ConversationScrollButtonProps) => {
|
||||||
|
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||||
|
|
||||||
|
const handleScrollToBottom = useCallback(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [scrollToBottom]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!isAtBottom && (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleScrollToBottom}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageText = (message: UIMessage): string =>
|
||||||
|
message.parts
|
||||||
|
.filter((part) => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
export type ConversationDownloadProps = Omit<
|
||||||
|
ComponentProps<typeof Button>,
|
||||||
|
"onClick"
|
||||||
|
> & {
|
||||||
|
messages: UIMessage[];
|
||||||
|
filename?: string;
|
||||||
|
formatMessage?: (message: UIMessage, index: number) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultFormatMessage = (message: UIMessage): string => {
|
||||||
|
const roleLabel =
|
||||||
|
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
||||||
|
return `**${roleLabel}:** ${getMessageText(message)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const messagesToMarkdown = (
|
||||||
|
messages: UIMessage[],
|
||||||
|
formatMessage: (
|
||||||
|
message: UIMessage,
|
||||||
|
index: number
|
||||||
|
) => string = defaultFormatMessage
|
||||||
|
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
||||||
|
|
||||||
|
export const ConversationDownload = ({
|
||||||
|
messages,
|
||||||
|
filename = "conversation.md",
|
||||||
|
formatMessage = defaultFormatMessage,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ConversationDownloadProps) => {
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const markdown = messagesToMarkdown(messages, formatMessage);
|
||||||
|
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [messages, filename, formatMessage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleDownload}
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <DownloadIcon className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Nodes } from "hast";
|
||||||
|
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
|
||||||
|
import type { Components } from "hast-util-to-jsx-runtime";
|
||||||
|
import type { ComponentProps, HTMLAttributes, MouseEvent } from "react";
|
||||||
|
import { Fragment, memo, useCallback, useMemo, useState } from "react";
|
||||||
|
import { jsx, jsxs } from "react/jsx-runtime";
|
||||||
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkParse from "remark-parse";
|
||||||
|
import remarkRehype from "remark-rehype";
|
||||||
|
import remend from "remend";
|
||||||
|
import { unified } from "unified";
|
||||||
|
|
||||||
|
// Raw HTML never reaches the tree: remark-rehype drops it (allowDangerousHtml
|
||||||
|
// is off by default) and rehype-sanitize is the second line of defence, mainly
|
||||||
|
// for its href protocol allow-list — that is what stops `javascript:` URLs.
|
||||||
|
// Relative hrefs carry no protocol, so storefront links pass through untouched.
|
||||||
|
const schema = {
|
||||||
|
...defaultSchema,
|
||||||
|
attributes: {
|
||||||
|
...defaultSchema.attributes,
|
||||||
|
// GFM task lists render as disabled checkboxes. The default schema allows
|
||||||
|
// `type` and `disabled` but not `checked`, so every box would read unticked.
|
||||||
|
input: [...(defaultSchema.attributes?.input ?? []), "checked"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkRehype)
|
||||||
|
.use(rehypeSanitize, schema);
|
||||||
|
|
||||||
|
// `text-only` leaves a half-streamed `[label](htt` as plain text. remend's
|
||||||
|
// default instead emits a `streamdown:incomplete-link` placeholder href, which
|
||||||
|
// the sanitizer would strip anyway.
|
||||||
|
const REMEND_OPTIONS = { linkMode: "text-only" } as const;
|
||||||
|
|
||||||
|
// remend only ever runs on text that is still arriving. On finished text it can
|
||||||
|
// do damage: it reads `*$89*` as an unclosed italic (the `$` throws off its
|
||||||
|
// closing-delimiter scan) and appends a stray `*`, which then parses as an empty
|
||||||
|
// bullet. Prices in italics are ordinary storefront copy, so settled messages
|
||||||
|
// render verbatim and only the in-flight one gets repaired.
|
||||||
|
const repair = (markdown: string, isStreaming: boolean) =>
|
||||||
|
isStreaming ? remend(markdown, REMEND_OPTIONS) : markdown;
|
||||||
|
|
||||||
|
export interface LinkSafetyConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
/** Return true for links that may open without a confirmation step. */
|
||||||
|
onLinkCheck?: (url: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnchorProps = ComponentProps<"a"> & {
|
||||||
|
linkSafety?: LinkSafetyConfig;
|
||||||
|
onUntrusted: (url: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LINK_CLASS =
|
||||||
|
"font-medium underline underline-offset-4 hover:text-foreground";
|
||||||
|
|
||||||
|
// Route-relative hrefs are the storefront links the assistant emits. Deciding
|
||||||
|
// this from the string alone keeps the server and client passes identical;
|
||||||
|
// `linkSafety.onLinkCheck` does the origin-aware check later, at click time.
|
||||||
|
const isRouteHref = (href: string) => /^[/#?]/.test(href);
|
||||||
|
|
||||||
|
const MarkdownLink = ({
|
||||||
|
href,
|
||||||
|
linkSafety,
|
||||||
|
onUntrusted,
|
||||||
|
...props
|
||||||
|
}: AnchorProps) => {
|
||||||
|
// Runs on click rather than on render so it can read `window.location`,
|
||||||
|
// which is unavailable during the server pass.
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(event: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (!href || !linkSafety?.enabled || !linkSafety.onLinkCheck) return;
|
||||||
|
if (linkSafety.onLinkCheck(href)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
onUntrusted(href);
|
||||||
|
},
|
||||||
|
[href, linkSafety, onUntrusted]
|
||||||
|
);
|
||||||
|
|
||||||
|
// In-app destinations navigate client-side in the same tab; sending a shopper
|
||||||
|
// to a product page in a new tab would strand the conversation behind it.
|
||||||
|
if (href && isRouteHref(href)) {
|
||||||
|
return <Link className={LINK_CLASS} href={href} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={LINK_CLASS}
|
||||||
|
href={href}
|
||||||
|
onClick={handleClick}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fenced code renders as plain preformatted text — no tokenizer, no themes.
|
||||||
|
const buildComponents = (
|
||||||
|
linkSafety: LinkSafetyConfig | undefined,
|
||||||
|
onUntrusted: (url: string) => void
|
||||||
|
): Partial<Components> => ({
|
||||||
|
a: (props: ComponentProps<"a">) => (
|
||||||
|
<MarkdownLink {...props} linkSafety={linkSafety} onUntrusted={onUntrusted} />
|
||||||
|
),
|
||||||
|
blockquote: (props: ComponentProps<"blockquote">) => (
|
||||||
|
<blockquote
|
||||||
|
className="my-3 border-border border-l-2 pl-3 text-muted-foreground"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
code: ({ className, ...props }: ComponentProps<"code">) => {
|
||||||
|
// Only fenced blocks carry a language class, and those already sit inside a
|
||||||
|
// <pre>, so they must not get the inline pill treatment.
|
||||||
|
const isBlock =
|
||||||
|
typeof className === "string" && className.includes("language-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
className={cn(
|
||||||
|
isBlock
|
||||||
|
? "font-mono text-xs"
|
||||||
|
: "rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
em: (props: ComponentProps<"em">) => <em className="italic" {...props} />,
|
||||||
|
h1: (props: ComponentProps<"h1">) => (
|
||||||
|
<h1 className="mt-4 mb-2 font-semibold text-base" {...props} />
|
||||||
|
),
|
||||||
|
h2: (props: ComponentProps<"h2">) => (
|
||||||
|
<h2 className="mt-4 mb-2 font-semibold text-base" {...props} />
|
||||||
|
),
|
||||||
|
h3: (props: ComponentProps<"h3">) => (
|
||||||
|
<h3 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
|
||||||
|
),
|
||||||
|
h4: (props: ComponentProps<"h4">) => (
|
||||||
|
<h4 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
|
||||||
|
),
|
||||||
|
h5: (props: ComponentProps<"h5">) => (
|
||||||
|
<h5 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
|
||||||
|
),
|
||||||
|
h6: (props: ComponentProps<"h6">) => (
|
||||||
|
<h6 className="mt-3 mb-1.5 font-semibold text-sm" {...props} />
|
||||||
|
),
|
||||||
|
hr: (props: ComponentProps<"hr">) => (
|
||||||
|
<hr className="my-4 border-border" {...props} />
|
||||||
|
),
|
||||||
|
// GFM task-list boxes are display only; readOnly silences React's warning
|
||||||
|
// about a `checked` input with no change handler.
|
||||||
|
input: (props: ComponentProps<"input">) => (
|
||||||
|
<input className="mr-1.5 align-middle" readOnly {...props} />
|
||||||
|
),
|
||||||
|
img: ({ alt, ...props }: ComponentProps<"img">) => (
|
||||||
|
// biome-ignore lint/nursery/noImgElement: model output, not a known asset
|
||||||
|
<img alt={alt ?? ""} className="my-2 max-w-full rounded-md" {...props} />
|
||||||
|
),
|
||||||
|
li: (props: ComponentProps<"li">) => <li className="my-0.5" {...props} />,
|
||||||
|
ol: (props: ComponentProps<"ol">) => (
|
||||||
|
<ol className="my-2 list-decimal space-y-0.5 pl-5" {...props} />
|
||||||
|
),
|
||||||
|
p: (props: ComponentProps<"p">) => (
|
||||||
|
<p className="my-2 leading-relaxed" {...props} />
|
||||||
|
),
|
||||||
|
pre: (props: ComponentProps<"pre">) => (
|
||||||
|
<pre
|
||||||
|
className="my-2 overflow-x-auto rounded-md bg-muted p-3 text-xs"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
strong: (props: ComponentProps<"strong">) => (
|
||||||
|
<strong className="font-semibold" {...props} />
|
||||||
|
),
|
||||||
|
table: (props: ComponentProps<"table">) => (
|
||||||
|
<div className="my-3 overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-left text-xs" {...props} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
td: (props: ComponentProps<"td">) => (
|
||||||
|
<td className="border border-border px-2 py-1" {...props} />
|
||||||
|
),
|
||||||
|
th: (props: ComponentProps<"th">) => (
|
||||||
|
<th
|
||||||
|
className="border border-border bg-muted px-2 py-1 font-medium"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
ul: (props: ComponentProps<"ul">) => (
|
||||||
|
<ul className="my-2 list-disc space-y-0.5 pl-5" {...props} />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MarkdownProps = Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||||
|
children: string;
|
||||||
|
/** True while tokens are still arriving; enables incomplete-syntax repair. */
|
||||||
|
isAnimating?: boolean;
|
||||||
|
linkSafety?: LinkSafetyConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Markdown = memo(
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
isAnimating,
|
||||||
|
linkSafety,
|
||||||
|
...props
|
||||||
|
}: MarkdownProps) => {
|
||||||
|
const [pending, setPending] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleUntrusted = useCallback((url: string) => setPending(url), []);
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
// While streaming, close syntax the model has not finished emitting so a
|
||||||
|
// partial `**bold` renders as bold rather than as literal asterisks.
|
||||||
|
const source = repair(children ?? "", isAnimating === true);
|
||||||
|
const tree = processor.runSync(processor.parse(source)) as Nodes;
|
||||||
|
|
||||||
|
return toJsxRuntime(tree, {
|
||||||
|
components: buildComponents(linkSafety, handleUntrusted),
|
||||||
|
Fragment,
|
||||||
|
jsx,
|
||||||
|
jsxs,
|
||||||
|
});
|
||||||
|
}, [children, isAnimating, linkSafety, handleUntrusted]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
<Dialog
|
||||||
|
onOpenChange={(open) => !open && setPending(null)}
|
||||||
|
open={pending !== null}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Leave this site?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This link points somewhere outside the store. Continue only if
|
||||||
|
you trust it.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="break-all rounded-md bg-muted px-3 py-2 font-mono text-xs">
|
||||||
|
{pending}
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setPending(null)} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (pending) {
|
||||||
|
window.open(pending, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
setPending(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(prev, next) =>
|
||||||
|
prev.children === next.children && prev.isAnimating === next.isAnimating
|
||||||
|
);
|
||||||
|
|
||||||
|
Markdown.displayName = "Markdown";
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ButtonGroup,
|
||||||
|
ButtonGroupText,
|
||||||
|
} from "@/components/ui/button-group";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { UIMessage } from "ai";
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { Markdown } from "./markdown";
|
||||||
|
|
||||||
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
from: UIMessage["role"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||||
|
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MessageContent = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageContentProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||||
|
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||||
|
"group-[.is-assistant]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageActionsProps = ComponentProps<"div">;
|
||||||
|
|
||||||
|
export const MessageActions = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageActionsProps) => (
|
||||||
|
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||||
|
tooltip?: string;
|
||||||
|
label?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageAction = ({
|
||||||
|
tooltip,
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
variant = "ghost",
|
||||||
|
size = "icon-sm",
|
||||||
|
...props
|
||||||
|
}: MessageActionProps) => {
|
||||||
|
const button = (
|
||||||
|
<Button size={size} type="button" variant={variant} {...props}>
|
||||||
|
{children}
|
||||||
|
<span className="sr-only">{label || tooltip}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MessageBranchContextType {
|
||||||
|
currentBranch: number;
|
||||||
|
totalBranches: number;
|
||||||
|
goToPrevious: () => void;
|
||||||
|
goToNext: () => void;
|
||||||
|
branches: ReactElement[];
|
||||||
|
setBranches: (branches: ReactElement[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
const useMessageBranch = () => {
|
||||||
|
const context = useContext(MessageBranchContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"MessageBranch components must be used within MessageBranch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
defaultBranch?: number;
|
||||||
|
onBranchChange?: (branchIndex: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MessageBranch = ({
|
||||||
|
defaultBranch = 0,
|
||||||
|
onBranchChange,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchProps) => {
|
||||||
|
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||||
|
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||||
|
|
||||||
|
const handleBranchChange = useCallback(
|
||||||
|
(newBranch: number) => {
|
||||||
|
setCurrentBranch(newBranch);
|
||||||
|
onBranchChange?.(newBranch);
|
||||||
|
},
|
||||||
|
[onBranchChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
const newBranch =
|
||||||
|
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||||
|
handleBranchChange(newBranch);
|
||||||
|
}, [currentBranch, branches.length, handleBranchChange]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
const newBranch =
|
||||||
|
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||||
|
handleBranchChange(newBranch);
|
||||||
|
}, [currentBranch, branches.length, handleBranchChange]);
|
||||||
|
|
||||||
|
const contextValue = useMemo<MessageBranchContextType>(
|
||||||
|
() => ({
|
||||||
|
branches,
|
||||||
|
currentBranch,
|
||||||
|
goToNext,
|
||||||
|
goToPrevious,
|
||||||
|
setBranches,
|
||||||
|
totalBranches: branches.length,
|
||||||
|
}),
|
||||||
|
[branches, currentBranch, goToNext, goToPrevious]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBranchContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MessageBranchContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const MessageBranchContent = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchContentProps) => {
|
||||||
|
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||||
|
const childrenArray = useMemo(
|
||||||
|
() => (Array.isArray(children) ? children : [children]),
|
||||||
|
[children]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use useEffect to update branches when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (branches.length !== childrenArray.length) {
|
||||||
|
setBranches(childrenArray);
|
||||||
|
}
|
||||||
|
}, [childrenArray, branches, setBranches]);
|
||||||
|
|
||||||
|
return childrenArray.map((branch, index) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||||
|
index === currentBranch ? "block" : "hidden"
|
||||||
|
)}
|
||||||
|
key={branch.key}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{branch}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
|
||||||
|
|
||||||
|
export const MessageBranchSelector = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchSelectorProps) => {
|
||||||
|
const { totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
// Don't render if there's only one branch
|
||||||
|
if (totalBranches <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroup
|
||||||
|
className={cn(
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
orientation="horizontal"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const MessageBranchPrevious = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchPreviousProps) => {
|
||||||
|
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="Previous branch"
|
||||||
|
disabled={totalBranches <= 1}
|
||||||
|
onClick={goToPrevious}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronLeftIcon size={14} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const MessageBranchNext = ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageBranchNextProps) => {
|
||||||
|
const { goToNext, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
aria-label="Next branch"
|
||||||
|
disabled={totalBranches <= 1}
|
||||||
|
onClick={goToNext}
|
||||||
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? <ChevronRightIcon size={14} />}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||||
|
|
||||||
|
export const MessageBranchPage = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MessageBranchPageProps) => {
|
||||||
|
const { currentBranch, totalBranches } = useMessageBranch();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonGroupText
|
||||||
|
className={cn(
|
||||||
|
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{currentBranch + 1} of {totalBranches}
|
||||||
|
</ButtonGroupText>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MessageResponseProps = ComponentProps<typeof Markdown>;
|
||||||
|
|
||||||
|
// Markdown already memoises on children/isAnimating.
|
||||||
|
export const MessageResponse = Markdown;
|
||||||
|
|
||||||
|
export type MessageToolbarProps = ComponentProps<"div">;
|
||||||
|
|
||||||
|
export const MessageToolbar = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageToolbarProps) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 flex w-full items-center justify-between gap-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { Markdown } from "./markdown";
|
||||||
|
import { Shimmer } from "./shimmer";
|
||||||
|
|
||||||
|
interface ReasoningContextValue {
|
||||||
|
isStreaming: boolean;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (open: boolean) => void;
|
||||||
|
duration: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useReasoning = () => {
|
||||||
|
const context = useContext(ReasoningContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("Reasoning components must be used within Reasoning");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||||
|
isStreaming?: boolean;
|
||||||
|
open?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AUTO_CLOSE_DELAY = 1000;
|
||||||
|
const MS_IN_S = 1000;
|
||||||
|
|
||||||
|
export const Reasoning = memo(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
isStreaming = false,
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
duration: durationProp,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ReasoningProps) => {
|
||||||
|
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||||
|
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||||
|
const isExplicitlyClosed = defaultOpen === false;
|
||||||
|
|
||||||
|
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||||
|
defaultProp: resolvedDefaultOpen,
|
||||||
|
onChange: onOpenChange,
|
||||||
|
prop: open,
|
||||||
|
});
|
||||||
|
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||||
|
defaultProp: undefined,
|
||||||
|
prop: durationProp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasEverStreamedRef = useRef(isStreaming);
|
||||||
|
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||||
|
const startTimeRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Track when streaming starts and compute duration
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming) {
|
||||||
|
hasEverStreamedRef.current = true;
|
||||||
|
if (startTimeRef.current === null) {
|
||||||
|
startTimeRef.current = Date.now();
|
||||||
|
}
|
||||||
|
} else if (startTimeRef.current !== null) {
|
||||||
|
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||||
|
startTimeRef.current = null;
|
||||||
|
}
|
||||||
|
}, [isStreaming, setDuration]);
|
||||||
|
|
||||||
|
// Auto-open when streaming starts (unless explicitly closed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||||
|
|
||||||
|
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
hasEverStreamedRef.current &&
|
||||||
|
!isStreaming &&
|
||||||
|
isOpen &&
|
||||||
|
!hasAutoClosed
|
||||||
|
) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
setHasAutoClosed(true);
|
||||||
|
}, AUTO_CLOSE_DELAY);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(newOpen: boolean) => {
|
||||||
|
setIsOpen(newOpen);
|
||||||
|
},
|
||||||
|
[setIsOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||||
|
[duration, isOpen, isStreaming, setIsOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReasoningContext.Provider value={contextValue}>
|
||||||
|
<Collapsible
|
||||||
|
className={cn("not-prose mb-4", className)}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
open={isOpen}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Collapsible>
|
||||||
|
</ReasoningContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ReasoningTriggerProps = ComponentProps<
|
||||||
|
typeof CollapsibleTrigger
|
||||||
|
> & {
|
||||||
|
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||||
|
if (isStreaming || duration === 0) {
|
||||||
|
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||||
|
}
|
||||||
|
if (duration === undefined) {
|
||||||
|
return <p>Thought for a few seconds</p>;
|
||||||
|
}
|
||||||
|
return <p>Thought for {duration} seconds</p>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReasoningTrigger = memo(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
getThinkingMessage = defaultGetThinkingMessage,
|
||||||
|
...props
|
||||||
|
}: ReasoningTriggerProps) => {
|
||||||
|
const { isStreaming, isOpen, duration } = useReasoning();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleTrigger
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<>
|
||||||
|
<BrainIcon className="size-4" />
|
||||||
|
{getThinkingMessage(isStreaming, duration)}
|
||||||
|
<ChevronDownIcon
|
||||||
|
className={cn(
|
||||||
|
"size-4 transition-transform",
|
||||||
|
isOpen ? "rotate-180" : "rotate-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ReasoningContentProps = ComponentProps<
|
||||||
|
typeof CollapsibleContent
|
||||||
|
> & {
|
||||||
|
children: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReasoningContent = memo(
|
||||||
|
({ className, children, ...props }: ReasoningContentProps) => {
|
||||||
|
const { isStreaming } = useReasoning();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsibleContent
|
||||||
|
className={cn(
|
||||||
|
"mt-4 text-sm",
|
||||||
|
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Markdown isAnimating={isStreaming}>{children}</Markdown>
|
||||||
|
</CollapsibleContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Reasoning.displayName = "Reasoning";
|
||||||
|
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||||
|
ReasoningContent.displayName = "ReasoningContent";
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { MotionProps } from "motion/react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import type { CSSProperties, ElementType, JSX } from "react";
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
|
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||||
|
|
||||||
|
// Cache motion components at module level to avoid creating during render
|
||||||
|
const motionComponentCache = new Map<
|
||||||
|
keyof JSX.IntrinsicElements,
|
||||||
|
React.ComponentType<MotionHTMLProps>
|
||||||
|
>();
|
||||||
|
|
||||||
|
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||||
|
let component = motionComponentCache.get(element);
|
||||||
|
if (!component) {
|
||||||
|
component = motion.create(element);
|
||||||
|
motionComponentCache.set(element, component);
|
||||||
|
}
|
||||||
|
return component;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TextShimmerProps {
|
||||||
|
children: string;
|
||||||
|
as?: ElementType;
|
||||||
|
className?: string;
|
||||||
|
duration?: number;
|
||||||
|
spread?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShimmerComponent = ({
|
||||||
|
children,
|
||||||
|
as: Component = "p",
|
||||||
|
className,
|
||||||
|
duration = 2,
|
||||||
|
spread = 2,
|
||||||
|
}: TextShimmerProps) => {
|
||||||
|
const MotionComponent = getMotionComponent(
|
||||||
|
Component as keyof JSX.IntrinsicElements
|
||||||
|
);
|
||||||
|
|
||||||
|
const dynamicSpread = useMemo(
|
||||||
|
() => (children?.length ?? 0) * spread,
|
||||||
|
[children, spread]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MotionComponent
|
||||||
|
animate={{ backgroundPosition: "0% center" }}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||||
|
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
initial={{ backgroundPosition: "100% center" }}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--spread": `${dynamicSpread}px`,
|
||||||
|
backgroundImage:
|
||||||
|
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||||
|
} as CSSProperties
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration,
|
||||||
|
ease: "linear",
|
||||||
|
repeat: Number.POSITIVE_INFINITY,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MotionComponent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Shimmer = memo(ShimmerComponent);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ScrollArea,
|
||||||
|
ScrollBar,
|
||||||
|
} from "@/components/ui/scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||||
|
|
||||||
|
export const Suggestions = ({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SuggestionsProps) => (
|
||||||
|
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||||
|
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<ScrollBar className="hidden" orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||||
|
suggestion: string;
|
||||||
|
onClick?: (suggestion: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Suggestion = ({
|
||||||
|
suggestion,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "sm",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SuggestionProps) => {
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onClick?.(suggestion);
|
||||||
|
}, [onClick, suggestion]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||||
|
onClick={handleClick}
|
||||||
|
size={size}
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children || suggestion}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useShopifyCart, 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 {
|
|
||||||
Empty,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyContent,
|
|
||||||
} from '@/components/ui/empty';
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
} from '@/components/ui/sheet';
|
|
||||||
|
|
||||||
const CartDrawer: React.FC = () => {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
closeCart,
|
|
||||||
items,
|
|
||||||
itemCount,
|
|
||||||
totalAmount,
|
|
||||||
checkoutUrl,
|
|
||||||
loading,
|
|
||||||
removeItem,
|
|
||||||
updateItemQuantity,
|
|
||||||
} = useShopifyCart();
|
|
||||||
|
|
||||||
const handleCheckout = () => {
|
|
||||||
if (checkoutUrl) {
|
|
||||||
redirectToCheckout(checkoutUrl);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getItemImage = (item: (typeof items)[0]) => {
|
|
||||||
return item.merchandise.image?.url;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSelectedOptions = (item: (typeof items)[0]) => {
|
|
||||||
return item.merchandise.selectedOptions ?? [];
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
<Button
|
|
||||||
onClick={closeCart}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CartDrawer;
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Typography } from '@/components/Typography';
|
|
||||||
|
|
||||||
interface CollectionImage {
|
|
||||||
url: string;
|
|
||||||
altText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Collection {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
handle: string;
|
|
||||||
description?: string;
|
|
||||||
image?: CollectionImage;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CollectionCardProps {
|
|
||||||
collection: Collection;
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CollectionCard;
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useCollectionProducts } from '@/hooks/use-shopify-collections';
|
|
||||||
import ProductCard from './product-card';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
|
|
||||||
const CollectionDetail: React.FC<{ handle?: string }> = ({ handle: handleProp }) => {
|
|
||||||
const handle = handleProp ?? '';
|
|
||||||
|
|
||||||
const { collection, loading, error, refetch } = useCollectionProducts(handle);
|
|
||||||
|
|
||||||
// Format title from handle
|
|
||||||
const formattedTitle = handle
|
|
||||||
? 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 products = collection?.products || [];
|
|
||||||
const title = collection?.title || formattedTitle;
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CollectionDetail;
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { FolderOpen } from "lucide-react";
|
|
||||||
import { CollectionGrid, type CollectionGridProps } from "@/components/commerce/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/commerce/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { useCollections } from '@/hooks/use-shopify-collections';
|
|
||||||
import CollectionCard from './collection-card';
|
|
||||||
|
|
||||||
const Collections: React.FC = () => {
|
|
||||||
const { collections, loading, error, refetch } = useCollections(20);
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* 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>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Collections Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
|
||||||
{collections.map((collection) => (
|
|
||||||
<CollectionCard key={collection.id} collection={collection} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Collections;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Star } from "lucide-react";
|
|
||||||
import { FeaturedProductView, type FeaturedProductProps } from "@/components/commerce/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Typography } from "@/components/Typography";
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
const aspectClass: Record<string, string> = {
|
|
||||||
portrait: "aspect-[4/5]",
|
|
||||||
square: "aspect-square",
|
|
||||||
landscape: "aspect-[4/3]",
|
|
||||||
};
|
|
||||||
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
) : 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>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ProductCard;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import ProductDetail from './product-detail/index.tsx';
|
|
||||||
|
|
||||||
export default ProductDetail;
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { useProduct, type Product } from '@/hooks/use-shopify-products';
|
|
||||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
|
||||||
import { useShopifyCart } 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';
|
|
||||||
|
|
||||||
interface ProductVariant {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
price: {
|
|
||||||
amount: string;
|
|
||||||
currencyCode: string;
|
|
||||||
};
|
|
||||||
availableForSale: boolean;
|
|
||||||
selectedOptions: Array<{
|
|
||||||
name: string;
|
|
||||||
value: string;
|
|
||||||
}>;
|
|
||||||
image?: {
|
|
||||||
url: string;
|
|
||||||
altText?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { Product };
|
|
||||||
|
|
||||||
interface ProductDetailProps {
|
|
||||||
handle?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductDetail: React.FC<ProductDetailProps> = ({ handle: handleProp }) => {
|
|
||||||
const routeHandle = useRouteSegment();
|
|
||||||
const handle = handleProp || routeHandle || '';
|
|
||||||
const { addItem, openCart } = useShopifyCart();
|
|
||||||
|
|
||||||
const { product, loading, error } = useProduct(handle);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Initialize variant when product loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (product) {
|
|
||||||
const firstVariant = product.variants.edges[0]?.node;
|
|
||||||
if (firstVariant) {
|
|
||||||
setSelectedVariant(firstVariant);
|
|
||||||
|
|
||||||
const initialOptions: Record<string, string> = {};
|
|
||||||
firstVariant.selectedOptions.forEach((option: { name: string; value: string }) => {
|
|
||||||
initialOptions[option.name] = option.value;
|
|
||||||
});
|
|
||||||
setSelectedOptions(initialOptions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [product]);
|
|
||||||
|
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToCart = async () => {
|
|
||||||
if (!selectedVariant || !product) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAddingToCart(true);
|
|
||||||
await addItem(selectedVariant.id, quantity);
|
|
||||||
openCart();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add item to cart:', err);
|
|
||||||
} finally {
|
|
||||||
setAddingToCart(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductDetail;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
|
|
||||||
interface ProductImage {
|
|
||||||
url: string;
|
|
||||||
altText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 || (() => {});
|
|
||||||
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image Thumbnails */}
|
|
||||||
{images.length > 1 && (
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{images.map((image, 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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={image.url}
|
|
||||||
alt={image.altText || 'Product thumbnail'}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProductDetailGallery;
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
interface ProductDetailInfoProps {
|
|
||||||
product: Product;
|
|
||||||
selectedVariant: ProductVariant | null;
|
|
||||||
selectedOptions: Record<string, string>;
|
|
||||||
quantity: number;
|
|
||||||
setQuantity: (quantity: number) => void;
|
|
||||||
handleAddToCart: () => void;
|
|
||||||
onOptionChange: (optionName: string, value: string) => void;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductDetailInfo: React.FC<ProductDetailInfoProps> = ({
|
|
||||||
product,
|
|
||||||
selectedVariant,
|
|
||||||
selectedOptions,
|
|
||||||
quantity,
|
|
||||||
setQuantity,
|
|
||||||
handleAddToCart,
|
|
||||||
onOptionChange,
|
|
||||||
loading = false,
|
|
||||||
}) => {
|
|
||||||
const formatPrice = (price: { amount: string; currencyCode: string }) => {
|
|
||||||
return new Intl.NumberFormat('en-US', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'USD',
|
|
||||||
}).format(parseFloat(price.amount));
|
|
||||||
};
|
|
||||||
|
|
||||||
const price = selectedVariant?.price || product.priceRange.minVariantPrice;
|
|
||||||
const compareAtPrice = product.compareAtPriceRange?.minVariantPrice;
|
|
||||||
const hasDiscount = compareAtPrice && parseFloat(compareAtPrice.amount) > parseFloat(price.amount);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl font-bold text-foreground mb-4 font-heading">
|
|
||||||
{product.title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{/* Price */}
|
|
||||||
<div className="flex items-center space-x-4 mb-6">
|
|
||||||
<span className="text-2xl font-bold text-foreground">
|
|
||||||
{formatPrice(price)}
|
|
||||||
</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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{product.description && (
|
|
||||||
<div className="text-muted-foreground mb-8 text-lg leading-relaxed">
|
|
||||||
{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;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Package } from "lucide-react";
|
|
||||||
import { ProductDetailsView, type ProductDetailsProps } from "@/components/commerce/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { LayoutGrid } from "lucide-react";
|
|
||||||
import {
|
|
||||||
ProductRecommendationsView,
|
|
||||||
type ProductRecommendationsProps,
|
|
||||||
} from "@/components/commerce/product-recommendations";
|
|
||||||
|
|
||||||
const productRecommendationsEditor: ComponentConfig<ProductRecommendationsProps> = {
|
|
||||||
label: "Product recommendations",
|
|
||||||
icon: <LayoutGrid size={16} />,
|
|
||||||
category: "commerce",
|
|
||||||
defaultProps: {
|
|
||||||
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 },
|
|
||||||
},
|
|
||||||
render: (props) => <ProductRecommendationsView {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default productRecommendationsEditor;
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import type { ShopifyProduct } from '@reacteditor/field-shopify';
|
|
||||||
import {
|
|
||||||
useProduct,
|
|
||||||
useProductRecommendations,
|
|
||||||
} from '@/hooks/use-shopify-products';
|
|
||||||
import { useRouteSegment } from '@/hooks/use-route-segment';
|
|
||||||
import { ProductCard } from './product-card';
|
|
||||||
|
|
||||||
export type ProductRecommendationsProps = {
|
|
||||||
product: ShopifyProduct | null;
|
|
||||||
heading: string;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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}
|
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ProductRecommendationsView;
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { GalleryHorizontalEnd } from "lucide-react";
|
|
||||||
import { ProductsCarousel, type ProductsCarouselProps } from "@/components/commerce/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-3/4 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="hidden md:inline-flex" />
|
|
||||||
<CarouselNext className="hidden md:inline-flex" />
|
|
||||||
</Carousel>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { LayoutGrid } from "lucide-react";
|
|
||||||
import { ProductsGrid, type ProductsGridProps } from "@/components/commerce/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import ProductCard from './product-card';
|
|
||||||
import { getProducts } from '@/hooks/use-shopify-products';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
|
||||||
|
|
||||||
interface ProductImage {
|
|
||||||
url: string;
|
|
||||||
altText?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductPrice {
|
|
||||||
amount: string;
|
|
||||||
currencyCode: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ProductsProps {
|
|
||||||
title?: string;
|
|
||||||
limit?: number;
|
|
||||||
showLoadMore?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Products: React.FC<ProductsProps> = ({
|
|
||||||
title = "Our Products",
|
|
||||||
limit = 12,
|
|
||||||
showLoadMore = true
|
|
||||||
}) => {
|
|
||||||
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 fetchProducts = async (currentProducts: Product[] = [], loadMore = false) => {
|
|
||||||
try {
|
|
||||||
if (loadMore) {
|
|
||||||
setLoadingMore(true);
|
|
||||||
} else {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newProducts = await getProducts({
|
|
||||||
first: limit,
|
|
||||||
sortKey: 'CREATED_AT',
|
|
||||||
reverse: true
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching products:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load products');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchProducts();
|
|
||||||
}, [limit]);
|
|
||||||
|
|
||||||
const handleAddToCart = async (product: Product) => {
|
|
||||||
// Here you would typically integrate with cart functionality
|
|
||||||
console.log('Adding to cart:', product);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadMore = () => {
|
|
||||||
if (!loadingMore && hasMoreProducts) {
|
|
||||||
fetchProducts(products, 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">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Loading Skeleton */}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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">
|
|
||||||
{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>
|
|
||||||
</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">
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{/* Products Grid */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8 mb-12">
|
|
||||||
{products.map((product) => (
|
|
||||||
<ProductCard
|
|
||||||
key={product.id}
|
|
||||||
product={product}
|
|
||||||
onAddToCart={handleAddToCart}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Load More Button */}
|
|
||||||
{showLoadMore && hasMoreProducts && (
|
|
||||||
<div className="text-center">
|
|
||||||
<Button
|
|
||||||
onClick={handleLoadMore}
|
|
||||||
disabled={loadingMore}
|
|
||||||
size="lg"
|
|
||||||
className="font-heading"
|
|
||||||
>
|
|
||||||
{loadingMore ? (
|
|
||||||
<span className="flex items-center space-x-2">
|
|
||||||
<Spinner size="sm" />
|
|
||||||
<span>Loading...</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
'Load More Products'
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Products;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Sparkles } from "lucide-react";
|
|
||||||
import { RecommendedProductsView, type RecommendedProductsProps } from "@/components/commerce/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
import { ComponentConfig } from '@reacteditor/core';
|
|
||||||
import { Search } from 'lucide-react';
|
|
||||||
import { SearchProductsView, type SearchProductsProps } from '@/components/commerce/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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Megaphone } from "lucide-react";
|
|
||||||
import { CTA, type CTAProps } from "@/components/cta/cta";
|
|
||||||
|
|
||||||
const ctaEditor: ComponentConfig<CTAProps> = {
|
|
||||||
label: "Call to action",
|
|
||||||
icon: <Megaphone size={16} />,
|
|
||||||
category: "content",
|
|
||||||
defaultProps: {
|
|
||||||
tagline: "",
|
|
||||||
heading: "Designed once. Worn for years.",
|
|
||||||
subheading:
|
|
||||||
"Join 40,000 people building a wardrobe they actually reach for.",
|
|
||||||
primaryCta: { label: "Shop now", href: "/collections" },
|
|
||||||
secondaryCta: { label: "Read our story", href: "/about" },
|
|
||||||
imageUrl:
|
|
||||||
"https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=2400&q=80",
|
|
||||||
align: "center",
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
|
||||||
heading: { label: "Heading", type: "textarea", contentEditable: true },
|
|
||||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
|
||||||
primaryCta: {
|
|
||||||
label: "Primary CTA",
|
|
||||||
type: "object",
|
|
||||||
objectFields: {
|
|
||||||
label: { label: "Label", type: "text", contentEditable: true },
|
|
||||||
href: { label: "Link", type: "text" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secondaryCta: {
|
|
||||||
label: "Secondary CTA",
|
|
||||||
type: "object",
|
|
||||||
objectFields: {
|
|
||||||
label: { label: "Label", type: "text", contentEditable: true },
|
|
||||||
href: { label: "Link", type: "text" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
imageUrl: { label: "Background image", type: "image" },
|
|
||||||
align: {
|
|
||||||
label: "Alignment",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Left", value: "left" },
|
|
||||||
{ label: "Center", value: "center" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <CTA {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ctaEditor;
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Heading } from "@/components/Heading";
|
|
||||||
|
|
||||||
export type CTAProps = {
|
|
||||||
tagline: string;
|
|
||||||
heading: string;
|
|
||||||
subheading: string;
|
|
||||||
primaryCta: { label: string; href: string };
|
|
||||||
secondaryCta: { label: string; href: string };
|
|
||||||
imageUrl: string;
|
|
||||||
align: "left" | "center";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CTA({
|
|
||||||
tagline,
|
|
||||||
heading,
|
|
||||||
subheading,
|
|
||||||
primaryCta,
|
|
||||||
secondaryCta,
|
|
||||||
imageUrl,
|
|
||||||
align,
|
|
||||||
}: CTAProps) {
|
|
||||||
return (
|
|
||||||
<section className="relative overflow-hidden py-24 md:py-32">
|
|
||||||
<div className="absolute inset-0 -z-10">
|
|
||||||
{imageUrl ? (
|
|
||||||
<>
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt=""
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/45" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="h-full w-full bg-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"container mx-auto flex max-w-4xl flex-col px-6 text-white",
|
|
||||||
align === "center" ? "items-center text-center" : "items-start",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Heading
|
|
||||||
tagline={tagline}
|
|
||||||
title={heading}
|
|
||||||
subtitle={subheading}
|
|
||||||
align={align === "center" ? "center" : "left"}
|
|
||||||
size="lg"
|
|
||||||
tone="light"
|
|
||||||
subtitleClassName="max-w-xl"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-10 flex flex-wrap gap-3",
|
|
||||||
align === "center" && "justify-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{primaryCta?.label ? (
|
|
||||||
<Link
|
|
||||||
href={primaryCta.href || "#"}
|
|
||||||
className="inline-flex items-center justify-center rounded-md bg-white px-6 py-3 text-sm font-medium tracking-wide text-black hover:opacity-90"
|
|
||||||
>
|
|
||||||
{primaryCta.label}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
{secondaryCta?.label ? (
|
|
||||||
<Link
|
|
||||||
href={secondaryCta.href || "#"}
|
|
||||||
className="inline-flex items-center justify-center rounded-md border border-white px-6 py-3 text-sm font-medium tracking-wide text-white hover:bg-white/10"
|
|
||||||
>
|
|
||||||
{secondaryCta.label}
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { HelpCircle } from "lucide-react";
|
|
||||||
import { FAQ, type FAQProps } from "@/components/faq/faq";
|
|
||||||
|
|
||||||
const faqEditor: ComponentConfig<FAQProps> = {
|
|
||||||
label: "FAQ",
|
|
||||||
icon: <HelpCircle size={16} />,
|
|
||||||
category: "content",
|
|
||||||
defaultProps: {
|
|
||||||
tagline: "Help",
|
|
||||||
heading: "Common questions",
|
|
||||||
subheading: "",
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
question: "What's your return policy?",
|
|
||||||
answer:
|
|
||||||
"Free returns within 30 days of delivery. Items should be unworn with original tags attached.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Where do you ship?",
|
|
||||||
answer:
|
|
||||||
"We ship worldwide. Free standard shipping on orders over $150 in the US, $250 international.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "How are your products made?",
|
|
||||||
answer:
|
|
||||||
"In small batches at family-run mills in Portugal, Italy, and Japan. Every piece is sampled and approved by our team.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "How do I care for my pieces?",
|
|
||||||
answer:
|
|
||||||
"Cold wash, lay flat to dry, iron when damp. Care details are on every product page and on the inner label.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
|
||||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
|
||||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
|
||||||
items: {
|
|
||||||
label: "Items",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { question: "", answer: "" },
|
|
||||||
getItemSummary: (it) => it?.question || "Question",
|
|
||||||
arrayFields: {
|
|
||||||
question: { label: "Question", type: "text", contentEditable: true },
|
|
||||||
answer: { label: "Answer", type: "textarea", contentEditable: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <FAQ {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default faqEditor;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { Plus, Minus } from "lucide-react";
|
|
||||||
import { Heading } from "@/components/Heading";
|
|
||||||
|
|
||||||
export type FAQProps = {
|
|
||||||
tagline: string;
|
|
||||||
heading: string;
|
|
||||||
subheading: string;
|
|
||||||
items: Array<{ question: string; answer: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function FAQ({ tagline, heading, subheading, items }: FAQProps) {
|
|
||||||
const [open, setOpen] = useState<number | null>(0);
|
|
||||||
return (
|
|
||||||
<section className="bg-background py-20 md:py-28">
|
|
||||||
<div className="container mx-auto max-w-3xl px-6">
|
|
||||||
<Heading
|
|
||||||
tagline={tagline}
|
|
||||||
title={heading}
|
|
||||||
subtitle={subheading}
|
|
||||||
align="center"
|
|
||||||
size="lg"
|
|
||||||
className="mb-12"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="divide-y divide-border border-y border-border">
|
|
||||||
{items.map((item, i) => {
|
|
||||||
const isOpen = open === i;
|
|
||||||
return (
|
|
||||||
<div key={i}>
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen(isOpen ? null : i)}
|
|
||||||
className="flex w-full items-center justify-between py-6 text-left"
|
|
||||||
>
|
|
||||||
<span className="text-base font-medium tracking-tight md:text-lg">
|
|
||||||
{item.question}
|
|
||||||
</span>
|
|
||||||
{isOpen ? (
|
|
||||||
<Minus size={18} strokeWidth={1.5} className="flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Plus size={18} strokeWidth={1.5} className="flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{isOpen ? (
|
|
||||||
<p className="pb-6 pr-8 text-sm leading-relaxed text-muted-foreground md:text-base">
|
|
||||||
{item.answer}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Sparkles } from "lucide-react";
|
|
||||||
import { Features, type FeaturesProps } from "@/components/features/features";
|
|
||||||
|
|
||||||
const featuresEditor: ComponentConfig<FeaturesProps> = {
|
|
||||||
label: "Features",
|
|
||||||
icon: <Sparkles size={16} />,
|
|
||||||
category: "content",
|
|
||||||
defaultProps: {
|
|
||||||
tagline: "Why us",
|
|
||||||
heading: "Built with intention",
|
|
||||||
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 — and nothing gets discounted into the bin.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Built to last",
|
|
||||||
body: "Reinforced seams, double-stitched edges, and finishes that age into something better.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
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: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "2 columns", value: "2" },
|
|
||||||
{ label: "3 columns", value: "3" },
|
|
||||||
{ label: "4 columns", value: "4" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
label: "Items",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { title: "Feature", body: "Description." },
|
|
||||||
getItemSummary: (it) => it?.title || "Feature",
|
|
||||||
arrayFields: {
|
|
||||||
title: { label: "Title", type: "text", contentEditable: true },
|
|
||||||
body: { label: "Body", type: "textarea", contentEditable: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <Features {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default featuresEditor;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Typography } from "@/components/Typography";
|
|
||||||
import { Container } from "@/components/layout/Container";
|
|
||||||
import { Heading } from "@/components/Heading";
|
|
||||||
|
|
||||||
export type FeaturesProps = {
|
|
||||||
tagline: string;
|
|
||||||
heading: string;
|
|
||||||
subheading: string;
|
|
||||||
columns: "2" | "3" | "4";
|
|
||||||
items: Array<{ title: string; body: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const colClass: Record<FeaturesProps["columns"], string> = {
|
|
||||||
"2": "md:grid-cols-2",
|
|
||||||
"3": "md:grid-cols-3",
|
|
||||||
"4": "md:grid-cols-2 lg:grid-cols-4",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Features({ tagline, heading, subheading, columns, items }: FeaturesProps) {
|
|
||||||
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-16"
|
|
||||||
maxWidth="max-w-2xl"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={`grid grid-cols-1 gap-x-10 gap-y-12 ${colClass[columns]}`}>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<div key={i} className="flex flex-col gap-3">
|
|
||||||
<Typography variant="caption">
|
|
||||||
{String(i + 1).padStart(2, "0")}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h5">{item.title}</Typography>
|
|
||||||
<Typography variant="body2" className="text-muted-foreground">
|
|
||||||
{item.body}
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { LayoutGrid } from "lucide-react";
|
|
||||||
import { Footer, type FooterProps } from "@/components/footer/footer";
|
|
||||||
|
|
||||||
const footerEditor: ComponentConfig<FooterProps> = {
|
|
||||||
label: "Footer",
|
|
||||||
icon: <LayoutGrid size={16} />,
|
|
||||||
category: "footer",
|
|
||||||
global: true,
|
|
||||||
defaultProps: {
|
|
||||||
brand: "Maison",
|
|
||||||
tagline:
|
|
||||||
"Considered essentials, made in small batches and built to last beyond the season.",
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
title: "Shop",
|
|
||||||
links: [
|
|
||||||
{ label: "All", href: "/collections" },
|
|
||||||
{ 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" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Legal",
|
|
||||||
links: [
|
|
||||||
{ label: "Terms", href: "/terms" },
|
|
||||||
{ label: "Privacy", href: "/privacy" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
social: [
|
|
||||||
{ label: "Instagram", href: "#" },
|
|
||||||
{ label: "Pinterest", href: "#" },
|
|
||||||
{ label: "TikTok", href: "#" },
|
|
||||||
],
|
|
||||||
showNewsletter: "yes",
|
|
||||||
newsletterHeading: "Stay in touch",
|
|
||||||
newsletterEndpoint: "",
|
|
||||||
copyright: "© 2026 Maison. All rights reserved.",
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
brand: { label: "Brand", type: "text", contentEditable: true },
|
|
||||||
tagline: { label: "Tagline", type: "textarea", contentEditable: true },
|
|
||||||
columns: {
|
|
||||||
label: "Columns",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { title: "Column", links: [] },
|
|
||||||
getItemSummary: (it) => it?.title || "Column",
|
|
||||||
arrayFields: {
|
|
||||||
title: { label: "Title", type: "text", contentEditable: true },
|
|
||||||
links: {
|
|
||||||
label: "Links",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { label: "Link", href: "/" },
|
|
||||||
getItemSummary: (it) => it?.label || "Link",
|
|
||||||
arrayFields: {
|
|
||||||
label: { label: "Label", type: "text", contentEditable: true },
|
|
||||||
href: { label: "Link", type: "text" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
social: {
|
|
||||||
label: "Social links",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { label: "Instagram", href: "#" },
|
|
||||||
getItemSummary: (it) => it?.label || "Social",
|
|
||||||
arrayFields: {
|
|
||||||
label: { label: "Label", type: "text", contentEditable: true },
|
|
||||||
href: { label: "Link", type: "text" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
showNewsletter: {
|
|
||||||
label: "Newsletter form",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Show", value: "yes" },
|
|
||||||
{ label: "Hide", value: "no" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
newsletterHeading: { label: "Newsletter heading", type: "text", contentEditable: true },
|
|
||||||
newsletterEndpoint: { label: "Newsletter endpoint", type: "text" },
|
|
||||||
copyright: { label: "Copyright", type: "text", contentEditable: true },
|
|
||||||
},
|
|
||||||
render: (props) => <Footer {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default footerEditor;
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { Typography } from "@/components/Typography";
|
|
||||||
import { Container } from "@/components/layout/Container";
|
|
||||||
|
|
||||||
export type FooterProps = {
|
|
||||||
brand: string;
|
|
||||||
tagline: string;
|
|
||||||
columns: Array<{
|
|
||||||
title: string;
|
|
||||||
links: Array<{ label: string; href: string }>;
|
|
||||||
}>;
|
|
||||||
social: Array<{ label: string; href: string }>;
|
|
||||||
showNewsletter: "yes" | "no";
|
|
||||||
newsletterHeading: string;
|
|
||||||
newsletterEndpoint: string;
|
|
||||||
copyright: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Footer({
|
|
||||||
brand,
|
|
||||||
tagline,
|
|
||||||
columns,
|
|
||||||
social,
|
|
||||||
showNewsletter,
|
|
||||||
newsletterHeading,
|
|
||||||
newsletterEndpoint,
|
|
||||||
copyright,
|
|
||||||
}: FooterProps) {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!email) return;
|
|
||||||
if (newsletterEndpoint) {
|
|
||||||
try {
|
|
||||||
await fetch(newsletterEndpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
});
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
setSubmitted(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-border bg-background">
|
|
||||||
<Container className="py-20 md:py-24">
|
|
||||||
<div className="grid grid-cols-1 gap-12 md:grid-cols-12">
|
|
||||||
<div className="md:col-span-4">
|
|
||||||
<Typography variant="h5" as="p">
|
|
||||||
{brand}
|
|
||||||
</Typography>
|
|
||||||
{tagline ? (
|
|
||||||
<Typography variant="body2" className="mt-3 max-w-sm text-muted-foreground">
|
|
||||||
{tagline}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{showNewsletter === "yes" ? (
|
|
||||||
<form onSubmit={submit} className="mt-8 max-w-sm">
|
|
||||||
<p className="text-sm font-medium">{newsletterHeading}</p>
|
|
||||||
{submitted ? (
|
|
||||||
<p className="mt-3 text-sm text-muted-foreground">
|
|
||||||
Thanks — we'll be in touch.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 flex border-b border-border focus-within:border-foreground">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="you@example.com"
|
|
||||||
className="flex-1 bg-transparent py-2 text-sm placeholder:text-muted-foreground focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="ml-3 text-sm font-medium tracking-wide hover:opacity-70"
|
|
||||||
>
|
|
||||||
Subscribe →
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-8 md:col-span-8 md:grid-cols-4">
|
|
||||||
{columns.map((col, i) => (
|
|
||||||
<div key={i}>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
||||||
{col.title}
|
|
||||||
</p>
|
|
||||||
<ul className="mt-4 space-y-2.5">
|
|
||||||
{col.links.map((l, j) => (
|
|
||||||
<li key={j}>
|
|
||||||
<Link
|
|
||||||
href={l.href}
|
|
||||||
className="text-sm text-foreground/80 hover:text-foreground"
|
|
||||||
>
|
|
||||||
{l.label}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-16 flex flex-col items-start justify-between gap-4 border-t border-border pt-8 md:flex-row md:items-center">
|
|
||||||
<p className="text-xs text-muted-foreground">{copyright}</p>
|
|
||||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
|
||||||
{social.map((s, i) => (
|
|
||||||
<a
|
|
||||||
key={i}
|
|
||||||
href={s.href}
|
|
||||||
className="text-xs uppercase tracking-[0.18em] text-foreground/70 hover:text-foreground"
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { LayoutTemplate } from "lucide-react";
|
|
||||||
import { Hero, type HeroProps } from "@/components/hero/hero";
|
|
||||||
|
|
||||||
const heroEditor: ComponentConfig<HeroProps> = {
|
|
||||||
label: "Hero",
|
|
||||||
icon: <LayoutTemplate size={16} />,
|
|
||||||
category: "hero",
|
|
||||||
defaultProps: {
|
|
||||||
tagline: "Spring 2026",
|
|
||||||
heading: "Made for the way you move",
|
|
||||||
subheading:
|
|
||||||
"A considered wardrobe of essentials, cut from natural fibers and designed to last.",
|
|
||||||
buttons: [
|
|
||||||
{ label: "Shop the collection", href: "/collections", variant: "primary" },
|
|
||||||
{ label: "Our story", href: "/about", variant: "secondary" },
|
|
||||||
],
|
|
||||||
imageUrl:
|
|
||||||
"https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=2400&q=80",
|
|
||||||
align: "left",
|
|
||||||
height: "lg",
|
|
||||||
tone: "dark",
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
|
||||||
heading: { label: "Heading", type: "textarea", contentEditable: true },
|
|
||||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
|
||||||
buttons: {
|
|
||||||
label: "Buttons",
|
|
||||||
type: "array",
|
|
||||||
arrayFields: {
|
|
||||||
label: { label: "Label", type: "text", contentEditable: true },
|
|
||||||
href: { label: "Link", type: "text" },
|
|
||||||
variant: {
|
|
||||||
label: "Variant",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "Primary (filled)", value: "primary" },
|
|
||||||
{ label: "Secondary (outline)", value: "secondary" },
|
|
||||||
{ label: "Outline", value: "outline" },
|
|
||||||
{ label: "Ghost", value: "ghost" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultItemProps: {
|
|
||||||
label: "Button",
|
|
||||||
href: "/",
|
|
||||||
variant: "primary",
|
|
||||||
},
|
|
||||||
getItemSummary: (item) => item?.label || "Button",
|
|
||||||
},
|
|
||||||
imageUrl: { label: "Background image", type: "image" },
|
|
||||||
align: {
|
|
||||||
label: "Alignment",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Left", value: "left" },
|
|
||||||
{ label: "Center", value: "center" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
label: "Height",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "Medium", value: "md" },
|
|
||||||
{ label: "Large", value: "lg" },
|
|
||||||
{ label: "Full", value: "full" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tone: {
|
|
||||||
label: "Tone",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Light", value: "light" },
|
|
||||||
{ label: "Dark", value: "dark" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <Hero {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default heroEditor;
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Typography } from "@/components/Typography";
|
|
||||||
|
|
||||||
export type HeroButtonVariant = "primary" | "secondary" | "outline" | "ghost";
|
|
||||||
|
|
||||||
export type HeroButton = {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
variant: HeroButtonVariant;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HeroProps = {
|
|
||||||
tagline: string;
|
|
||||||
heading: string;
|
|
||||||
subheading: string;
|
|
||||||
buttons: HeroButton[];
|
|
||||||
imageUrl: string;
|
|
||||||
align: "left" | "center";
|
|
||||||
height: "md" | "lg" | "full";
|
|
||||||
tone: "light" | "dark";
|
|
||||||
};
|
|
||||||
|
|
||||||
const heightClass: Record<HeroProps["height"], string> = {
|
|
||||||
md: "min-h-[60vh]",
|
|
||||||
lg: "min-h-[80vh]",
|
|
||||||
full: "min-h-screen",
|
|
||||||
};
|
|
||||||
|
|
||||||
function buttonClass(variant: HeroButtonVariant, isDark: boolean): string {
|
|
||||||
switch (variant) {
|
|
||||||
case "primary":
|
|
||||||
return cn(
|
|
||||||
"inline-flex items-center justify-center rounded-md px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-90",
|
|
||||||
isDark ? "bg-white text-black" : "bg-foreground text-background",
|
|
||||||
);
|
|
||||||
case "secondary":
|
|
||||||
case "outline":
|
|
||||||
return cn(
|
|
||||||
"inline-flex items-center justify-center rounded-md border px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
|
|
||||||
isDark ? "border-white text-white" : "border-foreground text-foreground",
|
|
||||||
);
|
|
||||||
case "ghost":
|
|
||||||
return cn(
|
|
||||||
"inline-flex items-center justify-center rounded-md px-6 py-3 text-sm font-medium tracking-wide transition-opacity hover:opacity-80",
|
|
||||||
isDark ? "text-white" : "text-foreground",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Hero({
|
|
||||||
tagline,
|
|
||||||
heading,
|
|
||||||
subheading,
|
|
||||||
buttons,
|
|
||||||
imageUrl,
|
|
||||||
align,
|
|
||||||
height,
|
|
||||||
tone,
|
|
||||||
}: HeroProps) {
|
|
||||||
const isDark = tone === "dark";
|
|
||||||
const visibleButtons = (buttons ?? []).filter((b) => b?.label);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={cn(
|
|
||||||
"relative flex w-full items-end overflow-hidden isolate",
|
|
||||||
heightClass[height],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{imageUrl ? (
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 z-0 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-[1]"
|
|
||||||
style={{
|
|
||||||
background: isDark
|
|
||||||
? "linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.55) 100%)"
|
|
||||||
: "linear-gradient(180deg, rgba(255,255,255,0.0) 30%, rgba(255,255,255,0.85) 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"container relative z-[2] mx-auto flex max-w-7xl flex-col px-6 py-20 md:py-28",
|
|
||||||
align === "center" ? "items-center text-center" : "items-start",
|
|
||||||
isDark ? "text-white" : "text-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tagline ? (
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
className={cn(
|
|
||||||
"mb-5",
|
|
||||||
isDark ? "text-white/80" : "text-foreground/70",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tagline}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
<Typography variant="h1" className="max-w-3xl">
|
|
||||||
{heading}
|
|
||||||
</Typography>
|
|
||||||
{subheading ? (
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
className={cn(
|
|
||||||
"mt-6 max-w-xl",
|
|
||||||
isDark ? "text-white/80" : "text-foreground/70",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{subheading}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{visibleButtons.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-10 flex flex-wrap gap-3",
|
|
||||||
align === "center" && "justify-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{visibleButtons.map((b, i) => (
|
|
||||||
<Link
|
|
||||||
key={`${b.href}-${b.label}-${i}`}
|
|
||||||
href={b.href || "#"}
|
|
||||||
className={buttonClass(b.variant, isDark)}
|
|
||||||
>
|
|
||||||
{b.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Megaphone } from "lucide-react";
|
|
||||||
import { Banner, type BannerProps } from "@/components/landing/banner";
|
|
||||||
|
|
||||||
const bannerEditor: ComponentConfig<BannerProps> = {
|
|
||||||
label: "Announcement bar",
|
|
||||||
icon: <Megaphone size={16} />,
|
|
||||||
category: "hero",
|
|
||||||
defaultProps: {
|
|
||||||
text: "Free shipping on orders over $150",
|
|
||||||
ctaLabel: "Shop new",
|
|
||||||
ctaHref: "/collections/new",
|
|
||||||
tone: "default",
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
text: { label: "Text", type: "text", contentEditable: true },
|
|
||||||
ctaLabel: { label: "CTA label", type: "text", contentEditable: true },
|
|
||||||
ctaHref: { label: "CTA link", type: "text" },
|
|
||||||
tone: {
|
|
||||||
label: "Tone",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "Default (dark)", value: "default" },
|
|
||||||
{ label: "Inverse (light)", value: "inverse" },
|
|
||||||
{ label: "Muted", value: "muted" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <Banner {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default bannerEditor;
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type BannerProps = {
|
|
||||||
text: string;
|
|
||||||
ctaLabel: string;
|
|
||||||
ctaHref: string;
|
|
||||||
tone: "default" | "inverse" | "muted";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Banner({ text, ctaLabel, ctaHref, tone }: BannerProps) {
|
|
||||||
const toneClass: Record<BannerProps["tone"], string> = {
|
|
||||||
default: "bg-foreground text-background",
|
|
||||||
inverse: "bg-background text-foreground border-y border-border",
|
|
||||||
muted: "bg-muted text-foreground border-y border-border",
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<div className={cn("w-full py-2 text-center text-xs tracking-[0.18em] uppercase", toneClass[tone])}>
|
|
||||||
<div className="container mx-auto flex flex-col items-center justify-center gap-2 px-6 sm:flex-row">
|
|
||||||
<span>{text}</span>
|
|
||||||
{ctaLabel ? (
|
|
||||||
<Link
|
|
||||||
href={ctaHref || "#"}
|
|
||||||
className="underline-offset-4 hover:underline"
|
|
||||||
>
|
|
||||||
{ctaLabel} →
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Images } from "lucide-react";
|
|
||||||
import { ImageGallery, type ImageGalleryProps } from "@/components/landing/image-gallery";
|
|
||||||
|
|
||||||
const imageGalleryEditor: ComponentConfig<ImageGalleryProps> = {
|
|
||||||
label: "Image gallery",
|
|
||||||
icon: <Images size={16} />,
|
|
||||||
category: "content",
|
|
||||||
defaultProps: {
|
|
||||||
tagline: "Lookbook",
|
|
||||||
heading: "Spring in the studio",
|
|
||||||
subheading: "",
|
|
||||||
layout: "editorial",
|
|
||||||
items: [
|
|
||||||
{ src: "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?auto=format&fit=crop&w=1600&q=80", alt: "" },
|
|
||||||
{ src: "https://images.unsplash.com/photo-1483985988355-763728e1935b?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
|
||||||
{ src: "https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
|
||||||
{ src: "https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
|
||||||
{ src: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&w=1200&q=80", alt: "" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
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: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "Grid", value: "grid" },
|
|
||||||
{ label: "Masonry", value: "masonry" },
|
|
||||||
{ label: "Editorial (mosaic)", value: "editorial" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
label: "Images",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { src: "", alt: "" },
|
|
||||||
getItemSummary: (it) => it?.caption || "Image",
|
|
||||||
arrayFields: {
|
|
||||||
src: { label: "Image", type: "image" },
|
|
||||||
alt: { label: "Alt text", type: "text", contentEditable: true },
|
|
||||||
caption: { label: "Caption", type: "text", contentEditable: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <ImageGallery {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default imageGalleryEditor;
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Container } from "@/components/layout/Container";
|
|
||||||
import { Heading } from "@/components/Heading";
|
|
||||||
|
|
||||||
export type ImageGalleryProps = {
|
|
||||||
tagline: string;
|
|
||||||
heading: string;
|
|
||||||
subheading: string;
|
|
||||||
layout: "grid" | "masonry" | "editorial";
|
|
||||||
items: Array<{ src: string; alt: string; caption?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ImageGallery({ tagline, heading, subheading, layout, items }: ImageGalleryProps) {
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{layout === "masonry" ? (
|
|
||||||
<div className="columns-1 gap-4 sm:columns-2 lg:columns-3">
|
|
||||||
{items.map((it, i) => (
|
|
||||||
<figure key={i} className="mb-4 break-inside-avoid">
|
|
||||||
<img
|
|
||||||
src={it.src}
|
|
||||||
alt={it.alt}
|
|
||||||
className="w-full rounded-md object-cover"
|
|
||||||
/>
|
|
||||||
{it.caption ? (
|
|
||||||
<figcaption className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{it.caption}
|
|
||||||
</figcaption>
|
|
||||||
) : null}
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : layout === "editorial" ? (
|
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
|
||||||
{items.slice(0, 5).map((it, i) => (
|
|
||||||
<figure
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
"overflow-hidden rounded-md bg-muted",
|
|
||||||
i === 0 && "md:col-span-7 md:row-span-2",
|
|
||||||
i === 1 && "md:col-span-5",
|
|
||||||
i === 2 && "md:col-span-5",
|
|
||||||
i === 3 && "md:col-span-6",
|
|
||||||
i === 4 && "md:col-span-6",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={it.src}
|
|
||||||
alt={it.alt}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{items.map((it, i) => (
|
|
||||||
<figure key={i}>
|
|
||||||
<img
|
|
||||||
src={it.src}
|
|
||||||
alt={it.alt}
|
|
||||||
className="aspect-[4/5] w-full rounded-md object-cover"
|
|
||||||
/>
|
|
||||||
{it.caption ? (
|
|
||||||
<figcaption className="mt-2 text-xs text-muted-foreground">
|
|
||||||
{it.caption}
|
|
||||||
</figcaption>
|
|
||||||
) : null}
|
|
||||||
</figure>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { ComponentConfig, Fields } from "@reacteditor/core";
|
|
||||||
import { Mail } from "lucide-react";
|
|
||||||
import { NewsletterCta, type NewsletterCtaProps } from "@/components/landing/newsletter-cta";
|
|
||||||
|
|
||||||
const baseFields: Fields<NewsletterCtaProps> = {
|
|
||||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
|
||||||
heading: { label: "Heading", type: "text", contentEditable: true },
|
|
||||||
subheading: { label: "Subheading", type: "textarea", contentEditable: true },
|
|
||||||
buttonLabel: { label: "Button label", type: "text", contentEditable: true },
|
|
||||||
emailProvider: {
|
|
||||||
label: "Email provider",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "None (custom endpoint)", value: "none" },
|
|
||||||
{ label: "Mailchimp", value: "mailchimp" },
|
|
||||||
{ label: "Klaviyo", value: "klaviyo" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
imageUrl: { label: "Image", type: "image" },
|
|
||||||
layout: {
|
|
||||||
label: "Layout",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Split (image + form)", value: "split" },
|
|
||||||
{ label: "Stacked (centered)", value: "stacked" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const newsletterCtaEditor: ComponentConfig<NewsletterCtaProps> = {
|
|
||||||
label: "Newsletter",
|
|
||||||
icon: <Mail size={16} />,
|
|
||||||
category: "content",
|
|
||||||
defaultProps: {
|
|
||||||
tagline: "Stay in the loop",
|
|
||||||
heading: "Letters from the studio",
|
|
||||||
subheading:
|
|
||||||
"New collections, mill stories, and the occasional invitation to in-person events. Twice a month.",
|
|
||||||
buttonLabel: "Subscribe",
|
|
||||||
emailProvider: "none",
|
|
||||||
endpoint: "",
|
|
||||||
mailchimpApiKey: "",
|
|
||||||
mailchimpServerPrefix: "",
|
|
||||||
mailchimpAudienceId: "",
|
|
||||||
klaviyoCompanyId: "",
|
|
||||||
klaviyoListId: "",
|
|
||||||
imageUrl:
|
|
||||||
"https://images.unsplash.com/photo-1469334031218-e382a71b716b?auto=format&fit=crop&w=1800&q=80",
|
|
||||||
layout: "split",
|
|
||||||
},
|
|
||||||
fields: baseFields,
|
|
||||||
resolveFields: (data) => {
|
|
||||||
const provider = data.props.emailProvider;
|
|
||||||
if (provider === "mailchimp") {
|
|
||||||
return {
|
|
||||||
...baseFields,
|
|
||||||
mailchimpApiKey: { label: "Mailchimp API key", type: "text" },
|
|
||||||
mailchimpServerPrefix: {
|
|
||||||
label: "Server prefix (e.g. us21)",
|
|
||||||
type: "text",
|
|
||||||
},
|
|
||||||
mailchimpAudienceId: { label: "Audience ID", type: "text" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (provider === "klaviyo") {
|
|
||||||
return {
|
|
||||||
...baseFields,
|
|
||||||
klaviyoCompanyId: { label: "Company ID (public API key)", type: "text" },
|
|
||||||
klaviyoListId: { label: "List ID", type: "text" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...baseFields,
|
|
||||||
endpoint: { label: "Submit endpoint", type: "text" },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
render: (props) => <NewsletterCta {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default newsletterCtaEditor;
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Container } from "@/components/layout/Container";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Heading } from "@/components/Heading";
|
|
||||||
|
|
||||||
export type EmailProvider = "none" | "mailchimp" | "klaviyo";
|
|
||||||
|
|
||||||
export type NewsletterCtaProps = {
|
|
||||||
tagline: string;
|
|
||||||
heading: string;
|
|
||||||
subheading: string;
|
|
||||||
buttonLabel: string;
|
|
||||||
emailProvider: EmailProvider;
|
|
||||||
endpoint?: string;
|
|
||||||
mailchimpApiKey?: string;
|
|
||||||
mailchimpServerPrefix?: string;
|
|
||||||
mailchimpAudienceId?: string;
|
|
||||||
klaviyoCompanyId?: string;
|
|
||||||
klaviyoListId?: string;
|
|
||||||
imageUrl: string;
|
|
||||||
layout: "split" | "stacked";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NewsletterCta({
|
|
||||||
tagline,
|
|
||||||
heading,
|
|
||||||
subheading,
|
|
||||||
buttonLabel,
|
|
||||||
emailProvider,
|
|
||||||
endpoint,
|
|
||||||
mailchimpApiKey,
|
|
||||||
mailchimpServerPrefix,
|
|
||||||
mailchimpAudienceId,
|
|
||||||
klaviyoCompanyId,
|
|
||||||
klaviyoListId,
|
|
||||||
imageUrl,
|
|
||||||
layout,
|
|
||||||
}: NewsletterCtaProps) {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [submitted, setSubmitted] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const submit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!email) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
if (emailProvider === "mailchimp") {
|
|
||||||
if (mailchimpServerPrefix && mailchimpAudienceId && mailchimpApiKey) {
|
|
||||||
await fetch(
|
|
||||||
`https://${mailchimpServerPrefix}.api.mailchimp.com/3.0/lists/${mailchimpAudienceId}/members`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${mailchimpApiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email_address: email,
|
|
||||||
status: "subscribed",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (emailProvider === "klaviyo") {
|
|
||||||
if (klaviyoCompanyId && klaviyoListId) {
|
|
||||||
await fetch(
|
|
||||||
`https://a.klaviyo.com/client/subscriptions/?company_id=${klaviyoCompanyId}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
revision: "2024-10-15",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
data: {
|
|
||||||
type: "subscription",
|
|
||||||
attributes: {
|
|
||||||
profile: {
|
|
||||||
data: {
|
|
||||||
type: "profile",
|
|
||||||
attributes: { email },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
relationships: {
|
|
||||||
list: { data: { type: "list", id: klaviyoListId } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (endpoint) {
|
|
||||||
await fetch(endpoint, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ email }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setSubmitted(true);
|
|
||||||
} catch {
|
|
||||||
setSubmitted(true);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const Form = (
|
|
||||||
<form
|
|
||||||
onSubmit={submit}
|
|
||||||
className="flex w-full max-w-md flex-col gap-3 sm:flex-row sm:items-center"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="Enter your email"
|
|
||||||
className="h-11 flex-1"
|
|
||||||
/>
|
|
||||||
<Button type="submit" size="lg" disabled={submitting} className="h-11">
|
|
||||||
{submitting ? "Joining…" : buttonLabel}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
|
|
||||||
const isStacked = layout === "stacked";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="bg-background">
|
|
||||||
<Container className="py-20 md:py-28">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"grid grid-cols-1 gap-10",
|
|
||||||
isStacked
|
|
||||||
? "mx-auto max-w-3xl items-center text-center"
|
|
||||||
: "items-center md:grid-cols-12 md:gap-16",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{imageUrl ? (
|
|
||||||
<div className={cn(!isStacked && "md:col-span-7")}>
|
|
||||||
<div className="relative overflow-hidden rounded-xl bg-muted">
|
|
||||||
<img
|
|
||||||
src={imageUrl}
|
|
||||||
alt=""
|
|
||||||
className={cn(
|
|
||||||
"w-full object-cover transition-transform duration-700 hover:scale-105",
|
|
||||||
isStacked ? "aspect-[16/9]" : "aspect-[4/3]",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex w-full flex-col",
|
|
||||||
isStacked ? "items-center" : "items-start md:col-span-5",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Heading
|
|
||||||
tagline={tagline}
|
|
||||||
title={heading}
|
|
||||||
subtitle={subheading}
|
|
||||||
align={isStacked ? "center" : "left"}
|
|
||||||
size="lg"
|
|
||||||
subtitleClassName={isStacked ? "mx-auto max-w-xl" : "max-w-md"}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-8 w-full",
|
|
||||||
isStacked && "flex justify-center",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{submitted ? (
|
|
||||||
<p className="text-sm font-medium uppercase tracking-wide">
|
|
||||||
You're in. See you Monday at 5:30am.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
Form
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type ContainerProps = React.HTMLAttributes<HTMLElement> & {
|
|
||||||
as?: React.ElementType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Container({
|
|
||||||
as: Comp = "div",
|
|
||||||
className,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
}: ContainerProps) {
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn("mx-auto w-full px-6", className)}
|
|
||||||
style={{ maxWidth: "var(--container-max-width, 80rem)", ...style }}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
/** Logo image URL. Falls back to the store name as a wordmark when absent. */
|
||||||
|
src?: string | null;
|
||||||
|
/** Wordmark text, and the image's alt text. */
|
||||||
|
storeName?: string;
|
||||||
|
/** Where the logo links to. Pass null to render it unwrapped. */
|
||||||
|
href?: string | null;
|
||||||
|
/** Sizing for the image — height only, so the aspect ratio is preserved. */
|
||||||
|
imageClassName?: string;
|
||||||
|
/** Sizing and weight for the wordmark fallback. */
|
||||||
|
textClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Logo: React.FC<LogoProps> = ({
|
||||||
|
src,
|
||||||
|
storeName = 'Shop',
|
||||||
|
href = '/',
|
||||||
|
imageClassName = 'h-6',
|
||||||
|
textClassName = 'text-xl',
|
||||||
|
}) => {
|
||||||
|
const mark = src ? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={storeName}
|
||||||
|
className={`w-auto object-contain ${imageClassName}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`font-medium tracking-tight text-foreground ${textClassName}`}
|
||||||
|
>
|
||||||
|
{storeName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href === null) {
|
||||||
|
return <span className="flex items-center">{mark}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} className="flex items-center">
|
||||||
|
{mark}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Logo;
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Award } from "lucide-react";
|
|
||||||
import { Logos, type LogosProps } from "@/components/logos/logos";
|
|
||||||
|
|
||||||
const logosEditor: ComponentConfig<LogosProps> = {
|
|
||||||
label: "Press / Logos",
|
|
||||||
icon: <Award size={16} />,
|
|
||||||
category: "content",
|
|
||||||
defaultProps: {
|
|
||||||
tagline: "As seen in",
|
|
||||||
layout: "row",
|
|
||||||
items: [
|
|
||||||
{ src: "https://logo.clearbit.com/vogue.com", alt: "Vogue" },
|
|
||||||
{ src: "https://logo.clearbit.com/highsnobiety.com", alt: "Highsnobiety" },
|
|
||||||
{ src: "https://logo.clearbit.com/gq.com", alt: "GQ" },
|
|
||||||
{ src: "https://logo.clearbit.com/dezeen.com", alt: "Dezeen" },
|
|
||||||
{ src: "https://logo.clearbit.com/wallpaper.com", alt: "Wallpaper*" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
tagline: { label: "Tagline", type: "text", contentEditable: true },
|
|
||||||
layout: {
|
|
||||||
label: "Layout",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Row", value: "row" },
|
|
||||||
{ label: "Marquee", value: "marquee" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
items: {
|
|
||||||
label: "Logos",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { src: "", alt: "" },
|
|
||||||
getItemSummary: (it) => it?.alt || "Logo",
|
|
||||||
arrayFields: {
|
|
||||||
src: { label: "Image", type: "image" },
|
|
||||||
alt: { label: "Alt text", type: "text", contentEditable: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <Logos {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default logosEditor;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { Container } from "@/components/layout/Container";
|
|
||||||
import { Typography } from "@/components/Typography";
|
|
||||||
|
|
||||||
export type LogosProps = {
|
|
||||||
tagline: string;
|
|
||||||
items: Array<{ src: string; alt: string }>;
|
|
||||||
layout: "row" | "marquee";
|
|
||||||
};
|
|
||||||
|
|
||||||
export function Logos({ tagline, items, layout }: LogosProps) {
|
|
||||||
return (
|
|
||||||
<section className="border-y border-border bg-muted/40 py-12">
|
|
||||||
<Container>
|
|
||||||
{tagline ? (
|
|
||||||
<Typography variant="caption" className="mb-8 text-center">
|
|
||||||
{tagline}
|
|
||||||
</Typography>
|
|
||||||
) : null}
|
|
||||||
{layout === "marquee" ? (
|
|
||||||
<div className="overflow-hidden">
|
|
||||||
<div className="flex animate-[marquee_30s_linear_infinite] gap-16 [--gap:4rem]">
|
|
||||||
{[...items, ...items].map((it, i) => (
|
|
||||||
<img
|
|
||||||
key={i}
|
|
||||||
src={it.src}
|
|
||||||
alt={it.alt}
|
|
||||||
className="h-7 w-auto opacity-60 grayscale"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap items-center justify-center gap-x-12 gap-y-6">
|
|
||||||
{items.map((it, i) => (
|
|
||||||
<img
|
|
||||||
key={i}
|
|
||||||
src={it.src}
|
|
||||||
alt={it.alt}
|
|
||||||
className="h-7 w-auto opacity-60 grayscale"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface AuroraTextProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
colors?: string[];
|
||||||
|
speed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuroraText = memo(
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
colors = ['#FF0080', '#7928CA', '#0070F3', '#38bdf8'],
|
||||||
|
speed = 1,
|
||||||
|
}: AuroraTextProps) => {
|
||||||
|
const animationDuration = `${10 / speed}s`;
|
||||||
|
|
||||||
|
const gradientStyle = {
|
||||||
|
backgroundImage: `linear-gradient(90deg, ${colors.join(', ')}, ${colors[0]})`,
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
backgroundClip: 'text',
|
||||||
|
animation: `aurora-flow ${animationDuration} ease-in-out infinite`,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('relative inline-block', className)}>
|
||||||
|
<span className="sr-only">{children}</span>
|
||||||
|
<span className="relative" style={gradientStyle} aria-hidden="true">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
AuroraText.displayName = 'AuroraText';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useRef, useEffect, useState } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface BlurFadeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
duration?: number;
|
||||||
|
delay?: number;
|
||||||
|
inView?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BlurFade({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
duration = 0.4,
|
||||||
|
delay = 0,
|
||||||
|
inView = false,
|
||||||
|
}: BlurFadeProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(!inView);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inView) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '-50px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ref.current) {
|
||||||
|
observer.observe(ref.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (ref.current) {
|
||||||
|
observer.unobserve(ref.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [inView]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
isVisible ? 'opacity-100 blur-none' : 'opacity-0 blur-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
animation: isVisible
|
||||||
|
? `blur-fade ${duration}s ease-out ${delay}s forwards`
|
||||||
|
: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { ComponentConfig } from "@reacteditor/core";
|
|
||||||
import { Menu as MenuIcon } from "lucide-react";
|
|
||||||
import { Navigation, type NavigationProps } from "@/components/navigation/navigation";
|
|
||||||
|
|
||||||
const navigationEditor: ComponentConfig<NavigationProps> = {
|
|
||||||
label: "Navigation",
|
|
||||||
icon: <MenuIcon size={16} />,
|
|
||||||
category: "navigation",
|
|
||||||
global: true,
|
|
||||||
defaultProps: {
|
|
||||||
brand: "Maison",
|
|
||||||
logo: "",
|
|
||||||
links: [
|
|
||||||
{ label: "Mens", href: "/collections/mens" },
|
|
||||||
{ label: "Womens", href: "/collections/womens" },
|
|
||||||
{ label: "Shop", href: "/search" },
|
|
||||||
{ label: "About", href: "/about" },
|
|
||||||
],
|
|
||||||
showSearch: "yes",
|
|
||||||
showCart: "yes",
|
|
||||||
sticky: "yes",
|
|
||||||
tone: "default",
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
logo: { label: "Logo", type: "image" },
|
|
||||||
brand: { label: "Logo Alt", type: "text", contentEditable: true },
|
|
||||||
links: {
|
|
||||||
label: "Links",
|
|
||||||
type: "array",
|
|
||||||
defaultItemProps: { label: "Link", href: "/" },
|
|
||||||
getItemSummary: (it) => it?.label || "Link",
|
|
||||||
arrayFields: {
|
|
||||||
label: { label: "Label", type: "text", contentEditable: true },
|
|
||||||
href: { label: "Link", type: "text" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
showSearch: {
|
|
||||||
label: "Search icon",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Show", value: "yes" },
|
|
||||||
{ label: "Hide", value: "no" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
showCart: {
|
|
||||||
label: "Cart icon",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Show", value: "yes" },
|
|
||||||
{ label: "Hide", value: "no" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
sticky: {
|
|
||||||
label: "Position",
|
|
||||||
type: "radio",
|
|
||||||
options: [
|
|
||||||
{ label: "Sticky", value: "yes" },
|
|
||||||
{ label: "Static", value: "no" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
tone: {
|
|
||||||
label: "Tone",
|
|
||||||
type: "select",
|
|
||||||
options: [
|
|
||||||
{ label: "Default", value: "default" },
|
|
||||||
{ label: "Muted", value: "muted" },
|
|
||||||
{ label: "Inverse (dark)", value: "inverse" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
render: (props) => <Navigation {...props} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default navigationEditor;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user