create-brainerce-store 1.51.0 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/index.js +9 -68
- package/messages/en.json +32 -0
- package/messages/he.json +32 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +9 -0
- package/templates/nextjs/base/AI-GUIDE.md +52 -3
- package/templates/nextjs/base/CLAUDE.md +10 -0
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
- package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
- package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
- package/templates/nextjs/base/tailwind.config.ts +69 -42
- package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
- package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
- package/templates/nextjs/designs/passepartout/globals.css +0 -412
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merchant-controlled site header.
|
|
3
|
+
*
|
|
4
|
+
* The merchant configures the header in the Brainerce dashboard under
|
|
5
|
+
* Sell → Content → Header
|
|
6
|
+
*
|
|
7
|
+
* Sticky, translucent warm-white bar: brand (serif) at the start, nav in the
|
|
8
|
+
* middle (desktop), actions at the end (language / account / cart / mobile
|
|
9
|
+
* menu). Everything is generic — nav labels and logo come from the dashboard.
|
|
10
|
+
* If `header` is null we render the same chrome with just brand + actions so
|
|
11
|
+
* the layout never collapses.
|
|
12
|
+
*/
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
import type { Content } from 'brainerce';
|
|
15
|
+
import { HeaderAccount } from './header-account';
|
|
16
|
+
import { HeaderCart } from './header-cart';
|
|
17
|
+
<% if (i18nEnabled) { %>
|
|
18
|
+
import { LanguageSwitcher } from '@/ui/layout/language-switcher';
|
|
19
|
+
<% } %>
|
|
20
|
+
import { IconMenu } from '@/ui/shared/icons';
|
|
21
|
+
import { resolveNavUrl } from '@/ui/layout/nav-url';
|
|
22
|
+
|
|
23
|
+
interface SiteHeaderProps {
|
|
24
|
+
/** Pre-fetched header payload (server-side). `null` triggers static fallback. */
|
|
25
|
+
header: Content<'HEADER'> | null;
|
|
26
|
+
/** Fallback brand label when the merchant hasn't uploaded a logo yet. */
|
|
27
|
+
storeName?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Brand({
|
|
31
|
+
logo,
|
|
32
|
+
brandLabel,
|
|
33
|
+
}: {
|
|
34
|
+
logo?: { src: string; alt: string } | null;
|
|
35
|
+
brandLabel: string;
|
|
36
|
+
}) {
|
|
37
|
+
return (
|
|
38
|
+
<a
|
|
39
|
+
href="/"
|
|
40
|
+
aria-label={brandLabel}
|
|
41
|
+
className="flex shrink-0 items-center gap-2 rounded-lg"
|
|
42
|
+
>
|
|
43
|
+
{logo ? (
|
|
44
|
+
// eslint-disable-next-line @next/next/no-img-element -- merchant-supplied URL, dynamic optimization handled by CDN
|
|
45
|
+
<img src={logo.src} alt={logo.alt} className="h-9 w-auto object-contain" />
|
|
46
|
+
) : (
|
|
47
|
+
<span className="font-display text-xl font-semibold leading-none text-foreground sm:text-2xl">
|
|
48
|
+
{brandLabel}
|
|
49
|
+
</span>
|
|
50
|
+
)}
|
|
51
|
+
</a>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
56
|
+
const data = header?.data;
|
|
57
|
+
const logo = data?.logo;
|
|
58
|
+
const navItems = data?.navItems ?? [];
|
|
59
|
+
const cta = data?.cta;
|
|
60
|
+
const brandLabel = logo?.alt || storeName || 'Store';
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<header className="sticky top-0 z-40 border-b bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
|
64
|
+
<div className="container-page flex h-16 items-center justify-between gap-4 sm:h-[4.5rem]">
|
|
65
|
+
<Brand logo={logo ?? null} brandLabel={brandLabel} />
|
|
66
|
+
|
|
67
|
+
{navItems.length > 0 && (
|
|
68
|
+
<nav aria-label="Main" className="hidden md:block">
|
|
69
|
+
<ul className="flex items-center gap-1">
|
|
70
|
+
{navItems.map((item, idx) => (
|
|
71
|
+
<li key={`${item.url}-${idx}`}>
|
|
72
|
+
<a
|
|
73
|
+
href={resolveNavUrl(item.url)}
|
|
74
|
+
className="rounded-full px-4 py-2 text-sm font-medium text-foreground/80 transition-colors hover:bg-secondary hover:text-foreground"
|
|
75
|
+
>
|
|
76
|
+
{item.label}
|
|
77
|
+
</a>
|
|
78
|
+
</li>
|
|
79
|
+
))}
|
|
80
|
+
</ul>
|
|
81
|
+
</nav>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
<div className="flex items-center gap-1 sm:gap-2">
|
|
85
|
+
{cta ? (
|
|
86
|
+
<a href={resolveNavUrl(cta.url)} className="btn-primary btn-sm hidden lg:inline-flex">
|
|
87
|
+
{cta.label}
|
|
88
|
+
</a>
|
|
89
|
+
) : null}
|
|
90
|
+
<% if (i18nEnabled) { %>
|
|
91
|
+
|
|
92
|
+
<LanguageSwitcher />
|
|
93
|
+
<% } %>
|
|
94
|
+
<HeaderAccount />
|
|
95
|
+
<HeaderCart />
|
|
96
|
+
|
|
97
|
+
{navItems.length > 0 && (
|
|
98
|
+
<details className="group relative md:hidden">
|
|
99
|
+
<summary
|
|
100
|
+
className="flex h-10 w-10 cursor-pointer list-none items-center justify-center rounded-full text-foreground transition-colors hover:bg-secondary [&::-webkit-details-marker]:hidden"
|
|
101
|
+
aria-label="Menu"
|
|
102
|
+
>
|
|
103
|
+
<IconMenu size={20} />
|
|
104
|
+
</summary>
|
|
105
|
+
<nav
|
|
106
|
+
aria-label="Mobile"
|
|
107
|
+
className="absolute end-0 top-12 z-50 w-56 rounded-xl border bg-background p-2 shadow-lg"
|
|
108
|
+
>
|
|
109
|
+
<ul className="flex flex-col">
|
|
110
|
+
{navItems.map((item, idx) => (
|
|
111
|
+
<li key={`m-${item.url}-${idx}`}>
|
|
112
|
+
<a
|
|
113
|
+
href={resolveNavUrl(item.url)}
|
|
114
|
+
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
|
115
|
+
>
|
|
116
|
+
{item.label}
|
|
117
|
+
</a>
|
|
118
|
+
</li>
|
|
119
|
+
))}
|
|
120
|
+
{cta ? (
|
|
121
|
+
<li className="mt-1 border-t pt-2">
|
|
122
|
+
<a
|
|
123
|
+
href={resolveNavUrl(cta.url)}
|
|
124
|
+
className="block rounded-lg px-3 py-2.5 text-sm font-semibold text-primary transition-colors hover:bg-secondary"
|
|
125
|
+
>
|
|
126
|
+
{cta.label}
|
|
127
|
+
</a>
|
|
128
|
+
</li>
|
|
129
|
+
) : null}
|
|
130
|
+
</ul>
|
|
131
|
+
</nav>
|
|
132
|
+
</details>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</header>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import type { ProductCustomizationField } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
7
|
+
import type { CustomizationValues } from '@/core/lib/product-options';
|
|
8
|
+
|
|
9
|
+
// The pure validation logic lives in core (zero markup) so that
|
|
10
|
+
// `core/hooks/use-product-page` never imports from `ui/`. Re-exported here
|
|
11
|
+
// for backward compatibility with existing imports.
|
|
12
|
+
export { validateCustomization } from '@/core/lib/product-options';
|
|
13
|
+
export type { CustomizationValues } from '@/core/lib/product-options';
|
|
14
|
+
|
|
15
|
+
interface CustomizationFieldsProps {
|
|
16
|
+
fields: ProductCustomizationField[];
|
|
17
|
+
values: CustomizationValues;
|
|
18
|
+
onChange: (values: CustomizationValues) => void;
|
|
19
|
+
errors?: Record<string, string>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* DESIGN ME — buyer-input customization fields on the product page
|
|
24
|
+
* (engraving text, gift date, uploaded artwork, …). Field definitions,
|
|
25
|
+
* values and validation errors come from useProductPage(); every input type
|
|
26
|
+
* below must stay functional (including the image/gallery uploads).
|
|
27
|
+
*/
|
|
28
|
+
export function CustomizationFields({
|
|
29
|
+
fields,
|
|
30
|
+
values,
|
|
31
|
+
onChange,
|
|
32
|
+
errors = {},
|
|
33
|
+
}: CustomizationFieldsProps) {
|
|
34
|
+
const t = useTranslations('customization');
|
|
35
|
+
|
|
36
|
+
if (fields.length === 0) return null;
|
|
37
|
+
|
|
38
|
+
const sorted = [...fields].sort((a, b) => a.position - b.position);
|
|
39
|
+
|
|
40
|
+
const setValue = (key: string, value: unknown) => {
|
|
41
|
+
onChange({ ...values, [key]: value });
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<fieldset className="mt-6 rounded-xl border bg-secondary/40 p-5">
|
|
46
|
+
<legend className="px-1.5 text-sm font-semibold text-foreground">{t('title')}</legend>
|
|
47
|
+
<div className="space-y-4">
|
|
48
|
+
{sorted.map((field) => (
|
|
49
|
+
<CustomizationFieldRow
|
|
50
|
+
key={field.definitionId}
|
|
51
|
+
field={field}
|
|
52
|
+
value={values[field.key]}
|
|
53
|
+
error={errors[field.key]}
|
|
54
|
+
onChange={(v) => setValue(field.key, v)}
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</fieldset>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface RowProps {
|
|
63
|
+
field: ProductCustomizationField;
|
|
64
|
+
value: unknown;
|
|
65
|
+
error?: string;
|
|
66
|
+
onChange: (value: unknown) => void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function CustomizationFieldRow({ field, value, error, onChange }: RowProps) {
|
|
70
|
+
const t = useTranslations('customization');
|
|
71
|
+
const labelText = (
|
|
72
|
+
<span className="mb-1.5 block text-sm font-medium text-foreground">
|
|
73
|
+
{field.name}
|
|
74
|
+
{field.required && (
|
|
75
|
+
<span aria-hidden="true" className="text-destructive">
|
|
76
|
+
{' '}
|
|
77
|
+
*
|
|
78
|
+
</span>
|
|
79
|
+
)}
|
|
80
|
+
</span>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const help = field.description ? (
|
|
84
|
+
<p className="mt-1.5 text-xs text-muted-foreground">{field.description}</p>
|
|
85
|
+
) : null;
|
|
86
|
+
|
|
87
|
+
const errorHint = error ? (
|
|
88
|
+
<p role="alert" className="mt-1.5 text-sm font-medium text-destructive">
|
|
89
|
+
{error}
|
|
90
|
+
</p>
|
|
91
|
+
) : null;
|
|
92
|
+
|
|
93
|
+
switch (field.type) {
|
|
94
|
+
case 'TEXTAREA':
|
|
95
|
+
return (
|
|
96
|
+
<div>
|
|
97
|
+
<label>
|
|
98
|
+
{labelText}
|
|
99
|
+
<textarea
|
|
100
|
+
className="input h-auto min-h-24 py-2.5"
|
|
101
|
+
value={typeof value === 'string' ? value : ''}
|
|
102
|
+
onChange={(e) => onChange(e.target.value)}
|
|
103
|
+
minLength={field.minLength ?? undefined}
|
|
104
|
+
maxLength={field.maxLength ?? undefined}
|
|
105
|
+
required={field.required}
|
|
106
|
+
rows={3}
|
|
107
|
+
/>
|
|
108
|
+
</label>
|
|
109
|
+
{help}
|
|
110
|
+
{errorHint}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
case 'NUMBER':
|
|
115
|
+
return (
|
|
116
|
+
<div>
|
|
117
|
+
<label>
|
|
118
|
+
{labelText}
|
|
119
|
+
<input
|
|
120
|
+
className="input"
|
|
121
|
+
type="number"
|
|
122
|
+
value={typeof value === 'number' ? value : ''}
|
|
123
|
+
onChange={(e) =>
|
|
124
|
+
onChange(e.target.value === '' ? undefined : Number(e.target.value))
|
|
125
|
+
}
|
|
126
|
+
min={field.minValue ?? undefined}
|
|
127
|
+
max={field.maxValue ?? undefined}
|
|
128
|
+
required={field.required}
|
|
129
|
+
/>
|
|
130
|
+
</label>
|
|
131
|
+
{help}
|
|
132
|
+
{errorHint}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
case 'BOOLEAN':
|
|
137
|
+
return (
|
|
138
|
+
<div>
|
|
139
|
+
<label className="inline-flex items-center gap-2">
|
|
140
|
+
<input
|
|
141
|
+
type="checkbox"
|
|
142
|
+
className="h-4 w-4 accent-primary"
|
|
143
|
+
checked={value === true}
|
|
144
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
145
|
+
/>
|
|
146
|
+
{labelText}
|
|
147
|
+
</label>
|
|
148
|
+
{help}
|
|
149
|
+
{errorHint}
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
case 'DATE':
|
|
154
|
+
return (
|
|
155
|
+
<div>
|
|
156
|
+
<label>
|
|
157
|
+
{labelText}
|
|
158
|
+
<input
|
|
159
|
+
className="input"
|
|
160
|
+
type="date"
|
|
161
|
+
value={typeof value === 'string' ? value : ''}
|
|
162
|
+
onChange={(e) => onChange(e.target.value)}
|
|
163
|
+
required={field.required}
|
|
164
|
+
/>
|
|
165
|
+
</label>
|
|
166
|
+
{help}
|
|
167
|
+
{errorHint}
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
case 'DATETIME':
|
|
172
|
+
return (
|
|
173
|
+
<div>
|
|
174
|
+
<label>
|
|
175
|
+
{labelText}
|
|
176
|
+
<input
|
|
177
|
+
className="input"
|
|
178
|
+
type="datetime-local"
|
|
179
|
+
value={typeof value === 'string' ? value : ''}
|
|
180
|
+
onChange={(e) => onChange(e.target.value)}
|
|
181
|
+
required={field.required}
|
|
182
|
+
/>
|
|
183
|
+
</label>
|
|
184
|
+
{help}
|
|
185
|
+
{errorHint}
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
case 'COLOR':
|
|
190
|
+
return (
|
|
191
|
+
<div>
|
|
192
|
+
<label>
|
|
193
|
+
{labelText}
|
|
194
|
+
<input
|
|
195
|
+
className="h-11 w-16 cursor-pointer rounded-lg border bg-background p-1"
|
|
196
|
+
type="color"
|
|
197
|
+
value={typeof value === 'string' && value ? value : '#000000'}
|
|
198
|
+
onChange={(e) => onChange(e.target.value)}
|
|
199
|
+
required={field.required}
|
|
200
|
+
/>
|
|
201
|
+
</label>
|
|
202
|
+
{help}
|
|
203
|
+
{errorHint}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
case 'SELECT': {
|
|
208
|
+
const options = field.enumValues ?? [];
|
|
209
|
+
return (
|
|
210
|
+
<div>
|
|
211
|
+
<label>
|
|
212
|
+
{labelText}
|
|
213
|
+
<select
|
|
214
|
+
className="input appearance-auto"
|
|
215
|
+
value={typeof value === 'string' ? value : ''}
|
|
216
|
+
onChange={(e) => onChange(e.target.value || undefined)}
|
|
217
|
+
required={field.required}
|
|
218
|
+
>
|
|
219
|
+
<option value="">{t('selectPlaceholder')}</option>
|
|
220
|
+
{options.map((opt) => (
|
|
221
|
+
<option key={opt.value} value={opt.value}>
|
|
222
|
+
{opt.label || opt.value}
|
|
223
|
+
</option>
|
|
224
|
+
))}
|
|
225
|
+
</select>
|
|
226
|
+
</label>
|
|
227
|
+
{help}
|
|
228
|
+
{errorHint}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case 'MULTI_SELECT': {
|
|
234
|
+
const options = field.enumValues ?? [];
|
|
235
|
+
const current = Array.isArray(value) ? (value as string[]) : [];
|
|
236
|
+
const toggle = (opt: string) => {
|
|
237
|
+
onChange(current.includes(opt) ? current.filter((v) => v !== opt) : [...current, opt]);
|
|
238
|
+
};
|
|
239
|
+
return (
|
|
240
|
+
<fieldset>
|
|
241
|
+
<legend>{labelText}</legend>
|
|
242
|
+
<div>
|
|
243
|
+
{options.map((opt) => (
|
|
244
|
+
<label key={opt.value} className="inline-flex items-center gap-2">
|
|
245
|
+
<input
|
|
246
|
+
type="checkbox"
|
|
247
|
+
className="h-4 w-4 accent-primary"
|
|
248
|
+
checked={current.includes(opt.value)}
|
|
249
|
+
onChange={() => toggle(opt.value)}
|
|
250
|
+
/>
|
|
251
|
+
{opt.label || opt.value}
|
|
252
|
+
</label>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
{help}
|
|
256
|
+
{errorHint}
|
|
257
|
+
</fieldset>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
case 'IMAGE':
|
|
262
|
+
return (
|
|
263
|
+
<SingleImageField
|
|
264
|
+
field={field}
|
|
265
|
+
value={typeof value === 'string' ? value : undefined}
|
|
266
|
+
onChange={onChange}
|
|
267
|
+
labelText={labelText}
|
|
268
|
+
help={help}
|
|
269
|
+
errorHint={errorHint}
|
|
270
|
+
/>
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
case 'GALLERY':
|
|
274
|
+
return (
|
|
275
|
+
<GalleryField
|
|
276
|
+
field={field}
|
|
277
|
+
value={Array.isArray(value) ? (value as string[]) : []}
|
|
278
|
+
onChange={onChange}
|
|
279
|
+
labelText={labelText}
|
|
280
|
+
help={help}
|
|
281
|
+
errorHint={errorHint}
|
|
282
|
+
/>
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
case 'URL':
|
|
286
|
+
return (
|
|
287
|
+
<div>
|
|
288
|
+
<label>
|
|
289
|
+
{labelText}
|
|
290
|
+
<input
|
|
291
|
+
className="input"
|
|
292
|
+
type="url"
|
|
293
|
+
value={typeof value === 'string' ? value : ''}
|
|
294
|
+
onChange={(e) => onChange(e.target.value)}
|
|
295
|
+
required={field.required}
|
|
296
|
+
/>
|
|
297
|
+
</label>
|
|
298
|
+
{help}
|
|
299
|
+
{errorHint}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
default:
|
|
304
|
+
return (
|
|
305
|
+
<div>
|
|
306
|
+
<label>
|
|
307
|
+
{labelText}
|
|
308
|
+
<input
|
|
309
|
+
className="input"
|
|
310
|
+
type="text"
|
|
311
|
+
value={typeof value === 'string' ? value : ''}
|
|
312
|
+
onChange={(e) => onChange(e.target.value)}
|
|
313
|
+
minLength={field.minLength ?? undefined}
|
|
314
|
+
maxLength={field.maxLength ?? undefined}
|
|
315
|
+
required={field.required}
|
|
316
|
+
/>
|
|
317
|
+
</label>
|
|
318
|
+
{help}
|
|
319
|
+
{errorHint}
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
interface FileFieldProps {
|
|
326
|
+
field: ProductCustomizationField;
|
|
327
|
+
labelText: React.ReactNode;
|
|
328
|
+
help: React.ReactNode;
|
|
329
|
+
errorHint: React.ReactNode;
|
|
330
|
+
onChange: (value: unknown) => void;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function SingleImageField({
|
|
334
|
+
field,
|
|
335
|
+
value,
|
|
336
|
+
onChange,
|
|
337
|
+
labelText,
|
|
338
|
+
help,
|
|
339
|
+
errorHint,
|
|
340
|
+
}: FileFieldProps & { value?: string }) {
|
|
341
|
+
const t = useTranslations('customization');
|
|
342
|
+
const [uploading, setUploading] = useState(false);
|
|
343
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
344
|
+
|
|
345
|
+
async function handleFile(file: File | null) {
|
|
346
|
+
if (!file) return;
|
|
347
|
+
setUploading(true);
|
|
348
|
+
setUploadError(null);
|
|
349
|
+
try {
|
|
350
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
351
|
+
const client = getClient();
|
|
352
|
+
const result = await client.uploadCustomizationFile(file);
|
|
353
|
+
onChange(result.url);
|
|
354
|
+
} catch (err) {
|
|
355
|
+
console.error('Upload failed', err);
|
|
356
|
+
setUploadError(t('uploadFailed'));
|
|
357
|
+
} finally {
|
|
358
|
+
setUploading(false);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div>
|
|
364
|
+
<label>
|
|
365
|
+
{labelText}
|
|
366
|
+
<input
|
|
367
|
+
className="block w-full cursor-pointer text-sm text-muted-foreground file:me-3 file:rounded-full file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
|
|
368
|
+
type="file"
|
|
369
|
+
accept="image/*"
|
|
370
|
+
onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
|
|
371
|
+
disabled={uploading}
|
|
372
|
+
required={field.required && !value}
|
|
373
|
+
/>
|
|
374
|
+
</label>
|
|
375
|
+
{uploading && (
|
|
376
|
+
<span>
|
|
377
|
+
<LoadingSpinner size="sm" />
|
|
378
|
+
{t('uploading')}
|
|
379
|
+
</span>
|
|
380
|
+
)}
|
|
381
|
+
{value && !uploading && (
|
|
382
|
+
<div>
|
|
383
|
+
<img src={value} alt="" className="h-20 w-20 rounded-lg border object-cover" />
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
{uploadError && <p role="alert">{uploadError}</p>}
|
|
387
|
+
{help}
|
|
388
|
+
{errorHint}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function GalleryField({
|
|
394
|
+
field,
|
|
395
|
+
value,
|
|
396
|
+
onChange,
|
|
397
|
+
labelText,
|
|
398
|
+
help,
|
|
399
|
+
errorHint,
|
|
400
|
+
}: FileFieldProps & { value: string[] }) {
|
|
401
|
+
const t = useTranslations('customization');
|
|
402
|
+
const [uploading, setUploading] = useState(false);
|
|
403
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
404
|
+
|
|
405
|
+
async function handleFiles(fileList: FileList | null) {
|
|
406
|
+
if (!fileList || fileList.length === 0) return;
|
|
407
|
+
setUploading(true);
|
|
408
|
+
setUploadError(null);
|
|
409
|
+
try {
|
|
410
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
411
|
+
const client = getClient();
|
|
412
|
+
const newUrls: string[] = [];
|
|
413
|
+
for (const file of Array.from(fileList)) {
|
|
414
|
+
const result = await client.uploadCustomizationFile(file);
|
|
415
|
+
newUrls.push(result.url);
|
|
416
|
+
}
|
|
417
|
+
onChange([...value, ...newUrls]);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
console.error('Upload failed', err);
|
|
420
|
+
setUploadError(t('uploadFailed'));
|
|
421
|
+
} finally {
|
|
422
|
+
setUploading(false);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function removeAt(idx: number) {
|
|
427
|
+
onChange(value.filter((_, i) => i !== idx));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return (
|
|
431
|
+
<div>
|
|
432
|
+
<label>
|
|
433
|
+
{labelText}
|
|
434
|
+
<input
|
|
435
|
+
className="block w-full cursor-pointer text-sm text-muted-foreground file:me-3 file:rounded-full file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90"
|
|
436
|
+
type="file"
|
|
437
|
+
accept="image/*"
|
|
438
|
+
multiple
|
|
439
|
+
onChange={(e) => handleFiles(e.target.files)}
|
|
440
|
+
disabled={uploading}
|
|
441
|
+
/>
|
|
442
|
+
</label>
|
|
443
|
+
{uploading && (
|
|
444
|
+
<span>
|
|
445
|
+
<LoadingSpinner size="sm" />
|
|
446
|
+
{t('uploading')}
|
|
447
|
+
</span>
|
|
448
|
+
)}
|
|
449
|
+
{value.length > 0 && (
|
|
450
|
+
<ul className="flex flex-wrap gap-2">
|
|
451
|
+
{value.map((url, idx) => (
|
|
452
|
+
<li key={`${url}-${idx}`}>
|
|
453
|
+
<img src={url} alt="" className="h-16 w-16 rounded-lg border object-cover" />
|
|
454
|
+
<button type="button" onClick={() => removeAt(idx)} aria-label={t('removeImage')}>
|
|
455
|
+
×
|
|
456
|
+
</button>
|
|
457
|
+
</li>
|
|
458
|
+
))}
|
|
459
|
+
</ul>
|
|
460
|
+
)}
|
|
461
|
+
{uploadError && <p role="alert">{uploadError}</p>}
|
|
462
|
+
{help}
|
|
463
|
+
{errorHint}
|
|
464
|
+
</div>
|
|
465
|
+
);
|
|
466
|
+
}
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import type { ProductDiscount } from 'brainerce';
|
|
2
|
-
import { cn } from '@/core/lib/utils';
|
|
3
|
-
|
|
4
|
-
interface DiscountBadgeProps {
|
|
5
|
-
discount?: ProductDiscount | null;
|
|
6
|
-
className?: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
>
|
|
19
|
-
{discount.badgeText}
|
|
20
|
-
</span>
|
|
21
|
-
);
|
|
22
|
-
}
|
|
1
|
+
import type { ProductDiscount } from 'brainerce';
|
|
2
|
+
import { cn } from '@/core/lib/utils';
|
|
3
|
+
|
|
4
|
+
interface DiscountBadgeProps {
|
|
5
|
+
discount?: ProductDiscount | null;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discount-rule badge shown on product cards; text comes from the product's
|
|
11
|
+
* `discount.badgeText` (merchant-configured rule).
|
|
12
|
+
*/
|
|
13
|
+
export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
|
|
14
|
+
if (!discount) return null;
|
|
15
|
+
|
|
16
|
+
return <span className={cn('badge-primary', className)}>{discount.badgeText}</span>;
|
|
17
|
+
}
|