create-brainerce-store 1.50.5 → 1.51.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/dist/index.js +171 -66
- package/messages/en.json +4 -1
- package/messages/he.json +4 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +51 -0
- package/templates/nextjs/base/.env.local.ejs +5 -0
- package/templates/nextjs/base/.eslintrc.json +57 -3
- package/templates/nextjs/base/AGENTS.md +41 -0
- package/templates/nextjs/base/AI-GUIDE.md +94 -0
- package/templates/nextjs/base/CLAUDE.md +42 -0
- package/templates/nextjs/base/TRANSLATIONS.md +1 -1
- package/templates/nextjs/base/next.config.ts +2 -2
- package/templates/nextjs/base/src/app/account/page.tsx +5 -5
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
- package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
- package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
- package/templates/nextjs/base/src/app/login/page.tsx +4 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
- package/templates/nextjs/base/src/app/page.tsx +4 -4
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
- package/templates/nextjs/base/src/app/products/page.tsx +4 -623
- package/templates/nextjs/base/src/app/register/page.tsx +4 -4
- package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
- package/templates/nextjs/base/src/app/sitemap.ts +1 -1
- package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
- package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
- package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
- package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +6 -6
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
- package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
- package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
- package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
- package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
- package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +29 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
- package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
- package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
- package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -73
- package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
- package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
- package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
- package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
- package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
- package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
- package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
- package/templates/nextjs/base/tailwind.config.ts +12 -0
- package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
- package/templates/nextjs/designs/passepartout/globals.css +412 -0
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
- package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
- package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
- package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
- package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
- package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
- package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +443 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
- package/templates/nextjs/base/src/app/home-client.tsx +0 -98
- /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
- /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +0 -0
package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx
RENAMED
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import type { ProductCustomizationField } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/lib/translations';
|
|
6
|
-
import { cn } from '@/lib/utils';
|
|
7
|
-
import { LoadingSpinner } from '@/
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
8
|
+
import type { CustomizationValues } from '@/core/lib/product-options';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
// The pure validation logic lives in core (zero markup) so that
|
|
11
|
+
// `core/hooks/use-product-page` never imports from `ui/`. Re-exported here
|
|
12
|
+
// for backward compatibility with existing imports.
|
|
13
|
+
export { validateCustomization } from '@/core/lib/product-options';
|
|
14
|
+
export type { CustomizationValues } from '@/core/lib/product-options';
|
|
10
15
|
|
|
11
16
|
interface CustomizationFieldsProps {
|
|
12
17
|
fields: ProductCustomizationField[];
|
|
@@ -192,8 +197,8 @@ function CustomizationFieldRow({ field, value, error, onChange }: RowProps) {
|
|
|
192
197
|
>
|
|
193
198
|
<option value="">{t('selectPlaceholder')}</option>
|
|
194
199
|
{options.map((opt) => (
|
|
195
|
-
<option key={opt} value={opt}>
|
|
196
|
-
{opt}
|
|
200
|
+
<option key={opt.value} value={opt.value}>
|
|
201
|
+
{opt.label || opt.value}
|
|
197
202
|
</option>
|
|
198
203
|
))}
|
|
199
204
|
</select>
|
|
@@ -214,14 +219,14 @@ function CustomizationFieldRow({ field, value, error, onChange }: RowProps) {
|
|
|
214
219
|
{labelText}
|
|
215
220
|
<div className="space-y-1.5">
|
|
216
221
|
{options.map((opt) => (
|
|
217
|
-
<label key={opt} className="me-4 inline-flex items-center gap-2">
|
|
222
|
+
<label key={opt.value} className="me-4 inline-flex items-center gap-2">
|
|
218
223
|
<input
|
|
219
224
|
type="checkbox"
|
|
220
|
-
checked={current.includes(opt)}
|
|
221
|
-
onChange={() => toggle(opt)}
|
|
225
|
+
checked={current.includes(opt.value)}
|
|
226
|
+
onChange={() => toggle(opt.value)}
|
|
222
227
|
className="h-4 w-4"
|
|
223
228
|
/>
|
|
224
|
-
<span className="text-foreground text-sm">{opt}</span>
|
|
229
|
+
<span className="text-foreground text-sm">{opt.label || opt.value}</span>
|
|
225
230
|
</label>
|
|
226
231
|
))}
|
|
227
232
|
</div>
|
|
@@ -316,7 +321,7 @@ function SingleImageField({
|
|
|
316
321
|
setUploading(true);
|
|
317
322
|
setUploadError(null);
|
|
318
323
|
try {
|
|
319
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
324
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
320
325
|
const client = getClient();
|
|
321
326
|
const result = await client.uploadCustomizationFile(file);
|
|
322
327
|
onChange(result.url);
|
|
@@ -374,7 +379,7 @@ function GalleryField({
|
|
|
374
379
|
setUploading(true);
|
|
375
380
|
setUploadError(null);
|
|
376
381
|
try {
|
|
377
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
382
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
378
383
|
const client = getClient();
|
|
379
384
|
const newUrls: string[] = [];
|
|
380
385
|
for (const file of Array.from(fileList)) {
|
|
@@ -437,42 +442,4 @@ function GalleryField({
|
|
|
437
442
|
{errorHint}
|
|
438
443
|
</div>
|
|
439
444
|
);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
export function validateCustomization(
|
|
443
|
-
fields: ProductCustomizationField[],
|
|
444
|
-
values: CustomizationValues
|
|
445
|
-
): Record<string, string> {
|
|
446
|
-
const errors: Record<string, string> = {};
|
|
447
|
-
for (const field of fields) {
|
|
448
|
-
const value = values[field.key];
|
|
449
|
-
const empty =
|
|
450
|
-
value === undefined ||
|
|
451
|
-
value === null ||
|
|
452
|
-
value === '' ||
|
|
453
|
-
(Array.isArray(value) && value.length === 0);
|
|
454
|
-
|
|
455
|
-
if (field.required && empty) {
|
|
456
|
-
errors[field.key] = 'required';
|
|
457
|
-
continue;
|
|
458
|
-
}
|
|
459
|
-
if (empty) continue;
|
|
460
|
-
|
|
461
|
-
if (field.type === 'SELECT') {
|
|
462
|
-
const allowed = field.enumValues ?? [];
|
|
463
|
-
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
464
|
-
errors[field.key] = 'invalidOption';
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
if (field.type === 'MULTI_SELECT') {
|
|
468
|
-
const allowed = field.enumValues ?? [];
|
|
469
|
-
if (
|
|
470
|
-
!Array.isArray(value) ||
|
|
471
|
-
!(value as unknown[]).every((v) => typeof v === 'string' && allowed.includes(v as string))
|
|
472
|
-
) {
|
|
473
|
-
errors[field.key] = 'invalidOption';
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
return errors;
|
|
478
|
-
}
|
|
445
|
+
}
|
package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx
RENAMED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import { CdnImage as Image } from '@/
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
5
|
import type { Product, ProductRecommendation } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { useCart, useStoreInfo } from '@/providers/store-provider';
|
|
8
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
9
|
-
import { useTranslations } from '@/lib/translations';
|
|
10
|
-
import { cn } from '@/lib/utils';
|
|
7
|
+
import { useCart, useStoreInfo } from '@/core/providers/store-provider';
|
|
8
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
9
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
+
import { cn } from '@/core/lib/utils';
|
|
11
11
|
|
|
12
12
|
interface FrequentlyBoughtTogetherProps {
|
|
13
13
|
items: ProductRecommendation[];
|
|
@@ -132,7 +132,7 @@ export function FrequentlyBoughtTogether({
|
|
|
132
132
|
if (adding || selected.size === 0) return;
|
|
133
133
|
try {
|
|
134
134
|
setAdding(true);
|
|
135
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
135
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
136
136
|
const client = getClient();
|
|
137
137
|
const selectedItems = crossSells.filter((item) => selected.has(item.id));
|
|
138
138
|
for (const item of selectedItems) {
|
package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx
RENAMED
|
@@ -23,7 +23,16 @@
|
|
|
23
23
|
* `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import type { Modifier, ModifierGroup
|
|
26
|
+
import type { Modifier, ModifierGroup } from 'brainerce';
|
|
27
|
+
|
|
28
|
+
// The pure selection logic (defaults, validation, wire-format adapter) lives
|
|
29
|
+
// in core (zero markup) so that `core/hooks/use-product-page` never imports
|
|
30
|
+
// from `ui/`. Re-exported here for backward compatibility with existing imports.
|
|
31
|
+
export {
|
|
32
|
+
buildInitialSelections,
|
|
33
|
+
toModifierSelections,
|
|
34
|
+
validateSelections,
|
|
35
|
+
} from '@/core/lib/product-options';
|
|
27
36
|
|
|
28
37
|
interface ModifierGroupSelectorProps {
|
|
29
38
|
/**
|
|
@@ -164,75 +173,3 @@ function ruleSummary(group: ModifierGroup): string {
|
|
|
164
173
|
}
|
|
165
174
|
return parts.length ? `(${parts.join(', ')})` : '';
|
|
166
175
|
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Lightweight client-side validation that mirrors the server's checks. Use
|
|
170
|
-
* this to gate the add-to-cart button — but always treat the server's 400
|
|
171
|
-
* (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
|
|
172
|
-
*/
|
|
173
|
-
export function validateSelections(
|
|
174
|
-
groups: ModifierGroup[],
|
|
175
|
-
selections: Record<string, string[]>
|
|
176
|
-
): string | null {
|
|
177
|
-
for (const group of groups) {
|
|
178
|
-
if (group.max === 0) continue; // disabled-for-variant — skip
|
|
179
|
-
const picks = selections[group.id] ?? [];
|
|
180
|
-
if (group.required && picks.length === 0) {
|
|
181
|
-
return `${group.name} is required`;
|
|
182
|
-
}
|
|
183
|
-
if (picks.length < group.min) {
|
|
184
|
-
return `Pick at least ${group.min} from ${group.name}`;
|
|
185
|
-
}
|
|
186
|
-
if (group.max != null && picks.length > group.max) {
|
|
187
|
-
return `Pick at most ${group.max} from ${group.name}`;
|
|
188
|
-
}
|
|
189
|
-
if (group.selectionType === 'SINGLE' && picks.length > 1) {
|
|
190
|
-
return `Only one option allowed in ${group.name}`;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* Adapter: convert the local Record<groupId, modifierIds> shape used by this
|
|
198
|
-
* component into the wire-format `ModifierSelection[]` array the SDK expects
|
|
199
|
-
* on `addToCart` / `updateCartItem`.
|
|
200
|
-
*/
|
|
201
|
-
export function toModifierSelections(
|
|
202
|
-
groups: ModifierGroup[],
|
|
203
|
-
selections: Record<string, string[]>
|
|
204
|
-
): ModifierSelection[] {
|
|
205
|
-
const out: ModifierSelection[] = [];
|
|
206
|
-
for (const group of groups) {
|
|
207
|
-
if (group.max === 0) continue;
|
|
208
|
-
const picks = selections[group.id] ?? [];
|
|
209
|
-
if (picks.length === 0) continue;
|
|
210
|
-
out.push({ modifierGroupId: group.id, modifierIds: picks });
|
|
211
|
-
}
|
|
212
|
-
return out;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Build the initial selection map from the group's defaults — call once on
|
|
217
|
-
* first render for each group. Honors `defaultModifierIds` first (per-attach
|
|
218
|
-
* defaults), falling back to per-modifier `isDefault` flags. Sold-out
|
|
219
|
-
* modifiers are filtered out so the user doesn't start with an invalid pick.
|
|
220
|
-
*/
|
|
221
|
-
export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
|
|
222
|
-
const out: Record<string, string[]> = {};
|
|
223
|
-
for (const group of groups) {
|
|
224
|
-
if (group.max === 0) continue;
|
|
225
|
-
const fromDefaults = group.defaultModifierIds ?? [];
|
|
226
|
-
const fromIsDefault = group.modifiers
|
|
227
|
-
.filter((m) => m.isDefault && m.available)
|
|
228
|
-
.map((m) => m.id);
|
|
229
|
-
const merged =
|
|
230
|
-
fromDefaults.length > 0
|
|
231
|
-
? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
|
|
232
|
-
: fromIsDefault;
|
|
233
|
-
// Cap by max for SINGLE groups — defaults can't violate selection rules.
|
|
234
|
-
const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
|
|
235
|
-
if (capped.length > 0) out[group.id] = capped;
|
|
236
|
-
}
|
|
237
|
-
return out;
|
|
238
|
-
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
5
|
-
import { cn } from '@/lib/utils';
|
|
4
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
5
|
+
import { cn } from '@/core/lib/utils';
|
|
6
6
|
|
|
7
7
|
interface PriceDisplayProps {
|
|
8
8
|
price: string | number;
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import { Link, useRouter } from '@/lib/navigation';
|
|
5
|
-
import { CdnImage as Image } from '@/
|
|
4
|
+
import { Link, useRouter } from '@/core/lib/navigation';
|
|
5
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
6
|
import type { Product } from 'brainerce';
|
|
7
7
|
import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import { PriceDisplay } from '@/
|
|
10
|
-
import { StockBadge } from '@/
|
|
11
|
-
import { DiscountBadge } from '@/
|
|
12
|
-
import { useCart } from '@/providers/store-provider';
|
|
13
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
14
|
-
import { cn } from '@/lib/utils';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
10
|
+
import { StockBadge } from '@/ui/product/stock-badge';
|
|
11
|
+
import { DiscountBadge } from '@/ui/product/discount-badge';
|
|
12
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
13
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
14
|
+
import { cn } from '@/core/lib/utils';
|
|
15
15
|
|
|
16
16
|
interface ProductCardProps {
|
|
17
17
|
product: Product;
|
|
@@ -115,7 +115,7 @@ export function ProductCard({ product, className }: ProductCardProps) {
|
|
|
115
115
|
|
|
116
116
|
try {
|
|
117
117
|
setAdding(true);
|
|
118
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
118
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
119
119
|
const client = getClient();
|
|
120
120
|
await client.smartAddToCart({ productId: product.id, quantity: 1 });
|
|
121
121
|
await refreshCart();
|
package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx
RENAMED
|
@@ -1,37 +1,19 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from '
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import { VariantSelector } from '@/components/products/variant-selector';
|
|
18
|
-
import { StockBadge } from '@/components/products/stock-badge';
|
|
19
|
-
import { RecommendationSection } from '@/components/products/recommendation-section';
|
|
20
|
-
import { FrequentlyBoughtTogether } from '@/components/products/frequently-bought-together';
|
|
21
|
-
import {
|
|
22
|
-
CustomizationFields,
|
|
23
|
-
validateCustomization,
|
|
24
|
-
type CustomizationValues,
|
|
25
|
-
} from '@/components/products/customization-fields';
|
|
26
|
-
import {
|
|
27
|
-
ModifierGroupSelector,
|
|
28
|
-
buildInitialSelections,
|
|
29
|
-
toModifierSelections,
|
|
30
|
-
validateSelections,
|
|
31
|
-
} from '@/components/products/modifier-group-selector';
|
|
32
|
-
import { useTranslations } from '@/lib/translations';
|
|
33
|
-
import { cn } from '@/lib/utils';
|
|
34
|
-
import { sanitizeProductHtml } from '@/lib/sanitize-html';
|
|
3
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
4
|
+
import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
|
|
5
|
+
import { useProductPage } from '@/core/hooks/use-product-page';
|
|
6
|
+
import { PriceDisplay } from '@/ui/product/price-display';
|
|
7
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
8
|
+
import { VariantSelector } from '@/ui/product/variant-selector';
|
|
9
|
+
import { StockBadge } from '@/ui/product/stock-badge';
|
|
10
|
+
import { RecommendationSection } from '@/ui/product/recommendation-section';
|
|
11
|
+
import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
|
|
12
|
+
import { CustomizationFields } from '@/ui/product/customization-fields';
|
|
13
|
+
import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
|
|
14
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
15
|
+
import { cn } from '@/core/lib/utils';
|
|
16
|
+
import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
|
|
35
17
|
|
|
36
18
|
/** Render a metafield value based on its type */
|
|
37
19
|
function MetafieldValue({ field }: { field: ProductMetafield }) {
|
|
@@ -122,176 +104,35 @@ interface ProductClientSectionProps {
|
|
|
122
104
|
}
|
|
123
105
|
|
|
124
106
|
export function ProductClientSection({ product: initialProduct }: ProductClientSectionProps) {
|
|
125
|
-
const { refreshCart } = useCart();
|
|
126
107
|
const t = useTranslations('productDetail');
|
|
127
108
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
);
|
|
156
|
-
const [modifierError, setModifierError] = useState<string | null>(null);
|
|
157
|
-
|
|
158
|
-
// Images list - switch main image when variant changes
|
|
159
|
-
const images: ProductImage[] = useMemo(() => {
|
|
160
|
-
return product?.images || [];
|
|
161
|
-
}, [product]);
|
|
162
|
-
|
|
163
|
-
// When variant changes, update selected image to variant image if available
|
|
164
|
-
useEffect(() => {
|
|
165
|
-
if (!selectedVariant?.image || !product) return;
|
|
166
|
-
|
|
167
|
-
const variantImgUrl =
|
|
168
|
-
typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
|
|
169
|
-
|
|
170
|
-
// Find if variant image exists in product images
|
|
171
|
-
const idx = images.findIndex((img) => img.url === variantImgUrl);
|
|
172
|
-
if (idx >= 0) {
|
|
173
|
-
setSelectedImageIndex(idx);
|
|
174
|
-
} else {
|
|
175
|
-
// Variant image not in product images - select index 0 as fallback
|
|
176
|
-
setSelectedImageIndex(-1);
|
|
177
|
-
}
|
|
178
|
-
}, [selectedVariant, images, product]);
|
|
179
|
-
|
|
180
|
-
// Determine which image to show
|
|
181
|
-
const mainImageUrl = useMemo(() => {
|
|
182
|
-
if (selectedImageIndex === -1 && selectedVariant?.image) {
|
|
183
|
-
const img = selectedVariant.image;
|
|
184
|
-
return typeof img === 'string' ? img : img.url;
|
|
185
|
-
}
|
|
186
|
-
return images[selectedImageIndex]?.url || null;
|
|
187
|
-
}, [selectedImageIndex, selectedVariant, images]);
|
|
188
|
-
|
|
189
|
-
// Price info - use variant price if selected, else product price
|
|
190
|
-
const priceInfo = useMemo(() => {
|
|
191
|
-
if (selectedVariant?.price) {
|
|
192
|
-
const variantBase = parseFloat(selectedVariant.price);
|
|
193
|
-
const variantSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
|
|
194
|
-
const variantEffective =
|
|
195
|
-
variantSale != null && variantSale < variantBase ? variantSale : variantBase;
|
|
196
|
-
|
|
197
|
-
// Overlay any product-level discount rule onto the variant price using the rule's ratio
|
|
198
|
-
if (product.discount) {
|
|
199
|
-
const ruleOriginal = parseFloat(product.discount.originalPrice) || 0;
|
|
200
|
-
const ruleDiscounted = parseFloat(product.discount.discountedPrice) || 0;
|
|
201
|
-
const ratio = ruleOriginal > 0 ? ruleDiscounted / ruleOriginal : 1;
|
|
202
|
-
const discounted = variantEffective * ratio;
|
|
203
|
-
const amount = Math.max(0, variantEffective - discounted);
|
|
204
|
-
return {
|
|
205
|
-
price: discounted,
|
|
206
|
-
originalPrice: variantEffective,
|
|
207
|
-
isOnSale: discounted < variantEffective,
|
|
208
|
-
discountAmount: amount,
|
|
209
|
-
discountPercent: variantEffective > 0 ? Math.round((amount / variantEffective) * 100) : 0,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
price: variantEffective,
|
|
215
|
-
originalPrice: variantBase,
|
|
216
|
-
isOnSale: variantEffective < variantBase,
|
|
217
|
-
discountPercent:
|
|
218
|
-
variantEffective < variantBase && variantBase > 0
|
|
219
|
-
? Math.round(((variantBase - variantEffective) / variantBase) * 100)
|
|
220
|
-
: 0,
|
|
221
|
-
};
|
|
222
|
-
}
|
|
223
|
-
return getProductPriceInfo(product);
|
|
224
|
-
}, [product, selectedVariant]);
|
|
225
|
-
|
|
226
|
-
// Inventory: use variant inventory if selected, else product inventory
|
|
227
|
-
const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
|
|
228
|
-
const canPurchase = inventory?.canPurchase !== false;
|
|
229
|
-
|
|
230
|
-
// Description
|
|
231
|
-
const description = useMemo(() => {
|
|
232
|
-
return product ? getDescriptionContent(product) : null;
|
|
233
|
-
}, [product]);
|
|
234
|
-
|
|
235
|
-
async function handleAddToCart() {
|
|
236
|
-
if (!product || addingToCart) return;
|
|
237
|
-
|
|
238
|
-
if (customizationFields.length > 0) {
|
|
239
|
-
const errs = validateCustomization(customizationFields, customizationValues);
|
|
240
|
-
if (Object.keys(errs).length > 0) {
|
|
241
|
-
setCustomizationErrors(errs);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
setCustomizationErrors({});
|
|
246
|
-
|
|
247
|
-
// Client-side modifier validation mirrors the server's checks. The server
|
|
248
|
-
// is authoritative — `MODIFIER_VALIDATION_FAILED` envelope on add-to-cart
|
|
249
|
-
// failure is the source of truth — but pre-flighting catches the obvious
|
|
250
|
-
// issues without a round-trip.
|
|
251
|
-
if (modifierGroups.length > 0) {
|
|
252
|
-
const error = validateSelections(modifierGroups, modifierSelections);
|
|
253
|
-
if (error) {
|
|
254
|
-
setModifierError(error);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
setModifierError(null);
|
|
259
|
-
|
|
260
|
-
const selections =
|
|
261
|
-
modifierGroups.length > 0
|
|
262
|
-
? toModifierSelections(modifierGroups, modifierSelections)
|
|
263
|
-
: undefined;
|
|
264
|
-
|
|
265
|
-
try {
|
|
266
|
-
setAddingToCart(true);
|
|
267
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
268
|
-
const client = getClient();
|
|
269
|
-
await client.smartAddToCart({
|
|
270
|
-
productId: product.id,
|
|
271
|
-
variantId: selectedVariant?.id,
|
|
272
|
-
quantity,
|
|
273
|
-
metadata:
|
|
274
|
-
customizationFields.length > 0 && Object.keys(customizationValues).length > 0
|
|
275
|
-
? customizationValues
|
|
276
|
-
: undefined,
|
|
277
|
-
...(selections && selections.length > 0 ? { selections } : {}),
|
|
278
|
-
});
|
|
279
|
-
await refreshCart();
|
|
280
|
-
setAddedMessage(true);
|
|
281
|
-
setTimeout(() => setAddedMessage(false), 2000);
|
|
282
|
-
} catch (err) {
|
|
283
|
-
// Surface the structured `MODIFIER_VALIDATION_FAILED` envelope when present.
|
|
284
|
-
const e = err as { details?: { code?: string; errors?: Array<{ message: string }> } };
|
|
285
|
-
const validationErrors = e?.details?.errors;
|
|
286
|
-
if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
|
|
287
|
-
setModifierError(validationErrors.map((v) => v.message).join('; '));
|
|
288
|
-
} else {
|
|
289
|
-
console.error('Failed to add to cart:', err);
|
|
290
|
-
}
|
|
291
|
-
} finally {
|
|
292
|
-
setAddingToCart(false);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
109
|
+
const {
|
|
110
|
+
product,
|
|
111
|
+
recommendations,
|
|
112
|
+
images,
|
|
113
|
+
selectedImageIndex,
|
|
114
|
+
setSelectedImageIndex,
|
|
115
|
+
mainImageUrl,
|
|
116
|
+
selectedVariant,
|
|
117
|
+
setSelectedVariant,
|
|
118
|
+
priceInfo,
|
|
119
|
+
inventory,
|
|
120
|
+
canPurchase,
|
|
121
|
+
description,
|
|
122
|
+
quantity,
|
|
123
|
+
setQuantity,
|
|
124
|
+
addingToCart,
|
|
125
|
+
addedMessage,
|
|
126
|
+
handleAddToCart,
|
|
127
|
+
customizationFields,
|
|
128
|
+
customizationValues,
|
|
129
|
+
setCustomizationValues,
|
|
130
|
+
customizationErrors,
|
|
131
|
+
modifierGroups,
|
|
132
|
+
modifierSelections,
|
|
133
|
+
setModifierSelection,
|
|
134
|
+
modifierError,
|
|
135
|
+
} = useProductPage(initialProduct);
|
|
295
136
|
|
|
296
137
|
return (
|
|
297
138
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
@@ -384,10 +225,10 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
384
225
|
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
|
385
226
|
|
|
386
227
|
{/* Tags */}
|
|
387
|
-
{(product as { tags?: Array<{ id: string; name: string }> }).tags &&
|
|
388
|
-
(product as { tags: Array<{ id: string; name: string }> }).tags.length > 0 && (
|
|
228
|
+
{(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
|
|
229
|
+
(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length > 0 && (
|
|
389
230
|
<div className="flex flex-wrap gap-1.5">
|
|
390
|
-
{(product as { tags: Array<{ id: string; name: string }> }).tags.map((tag) => (
|
|
231
|
+
{(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map((tag) => (
|
|
391
232
|
<span
|
|
392
233
|
key={tag.id}
|
|
393
234
|
className="border-border text-muted-foreground rounded-full border px-2.5 py-0.5 text-xs"
|
|
@@ -490,9 +331,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
490
331
|
key={group.attachmentId ?? group.id}
|
|
491
332
|
group={group}
|
|
492
333
|
value={modifierSelections[group.id] ?? []}
|
|
493
|
-
onChange={(next) =>
|
|
494
|
-
setModifierSelections((prev) => ({ ...prev, [group.id]: next }))
|
|
495
|
-
}
|
|
334
|
+
onChange={(next) => setModifierSelection(group.id, next)}
|
|
496
335
|
disabled={addingToCart}
|
|
497
336
|
/>
|
|
498
337
|
))}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import type { Product } from 'brainerce';
|
|
4
|
-
import { useTranslations } from '@/lib/translations';
|
|
5
|
-
import { ProductCard } from '@/
|
|
6
|
-
import { cn } from '@/lib/utils';
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { ProductCard } from '@/ui/product/product-card';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
7
|
|
|
8
8
|
interface ProductGridProps {
|
|
9
9
|
products: Product[];
|