create-brainerce-store 1.50.1 → 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 +18 -10
- package/templates/nextjs/base/package.json.ejs +5 -1
- 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 +16 -14
- 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/image-hosts.ts +7 -0
- 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-html.ts +12 -0
- 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/middleware.ts.ejs +2 -2
- 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 -77
- 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/ui/shared/cdn-image.tsx +26 -0
- 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/components/products/allergen-chips.tsx +0 -78
- /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}/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}/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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure product-option logic (zero markup) shared by `core/hooks/use-product-page`
|
|
3
|
+
* and the `ui/product` option components. Moved verbatim from
|
|
4
|
+
* `customization-fields.tsx` / `modifier-group-selector.tsx` so that core
|
|
5
|
+
* never depends on `ui/` — the UI components re-export these for
|
|
6
|
+
* backward compatibility.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ModifierGroup, ModifierSelection, ProductCustomizationField } from 'brainerce';
|
|
10
|
+
|
|
11
|
+
export type CustomizationValues = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export function validateCustomization(
|
|
14
|
+
fields: ProductCustomizationField[],
|
|
15
|
+
values: CustomizationValues
|
|
16
|
+
): Record<string, string> {
|
|
17
|
+
const errors: Record<string, string> = {};
|
|
18
|
+
for (const field of fields) {
|
|
19
|
+
const value = values[field.key];
|
|
20
|
+
const empty =
|
|
21
|
+
value === undefined ||
|
|
22
|
+
value === null ||
|
|
23
|
+
value === '' ||
|
|
24
|
+
(Array.isArray(value) && value.length === 0);
|
|
25
|
+
|
|
26
|
+
if (field.required && empty) {
|
|
27
|
+
errors[field.key] = 'required';
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (empty) continue;
|
|
31
|
+
|
|
32
|
+
if (field.type === 'SELECT') {
|
|
33
|
+
const allowed = (field.enumValues ?? []).map((o) => o.value);
|
|
34
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
35
|
+
errors[field.key] = 'invalidOption';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (field.type === 'MULTI_SELECT') {
|
|
39
|
+
const allowed = (field.enumValues ?? []).map((o) => o.value);
|
|
40
|
+
if (
|
|
41
|
+
!Array.isArray(value) ||
|
|
42
|
+
!(value as unknown[]).every((v) => typeof v === 'string' && allowed.includes(v as string))
|
|
43
|
+
) {
|
|
44
|
+
errors[field.key] = 'invalidOption';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lightweight client-side validation that mirrors the server's checks. Use
|
|
53
|
+
* this to gate the add-to-cart button — but always treat the server's 400
|
|
54
|
+
* (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
|
|
55
|
+
*/
|
|
56
|
+
export function validateSelections(
|
|
57
|
+
groups: ModifierGroup[],
|
|
58
|
+
selections: Record<string, string[]>
|
|
59
|
+
): string | null {
|
|
60
|
+
for (const group of groups) {
|
|
61
|
+
if (group.max === 0) continue; // disabled-for-variant — skip
|
|
62
|
+
const picks = selections[group.id] ?? [];
|
|
63
|
+
if (group.required && picks.length === 0) {
|
|
64
|
+
return `${group.name} is required`;
|
|
65
|
+
}
|
|
66
|
+
if (picks.length < group.min) {
|
|
67
|
+
return `Pick at least ${group.min} from ${group.name}`;
|
|
68
|
+
}
|
|
69
|
+
if (group.max != null && picks.length > group.max) {
|
|
70
|
+
return `Pick at most ${group.max} from ${group.name}`;
|
|
71
|
+
}
|
|
72
|
+
if (group.selectionType === 'SINGLE' && picks.length > 1) {
|
|
73
|
+
return `Only one option allowed in ${group.name}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Adapter: convert the local Record<groupId, modifierIds> shape used by the
|
|
81
|
+
* selector component into the wire-format `ModifierSelection[]` array the SDK
|
|
82
|
+
* expects on `addToCart` / `updateCartItem`.
|
|
83
|
+
*/
|
|
84
|
+
export function toModifierSelections(
|
|
85
|
+
groups: ModifierGroup[],
|
|
86
|
+
selections: Record<string, string[]>
|
|
87
|
+
): ModifierSelection[] {
|
|
88
|
+
const out: ModifierSelection[] = [];
|
|
89
|
+
for (const group of groups) {
|
|
90
|
+
if (group.max === 0) continue;
|
|
91
|
+
const picks = selections[group.id] ?? [];
|
|
92
|
+
if (picks.length === 0) continue;
|
|
93
|
+
out.push({ modifierGroupId: group.id, modifierIds: picks });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the initial selection map from the group's defaults — call once on
|
|
100
|
+
* first render for each group. Honors `defaultModifierIds` first (per-attach
|
|
101
|
+
* defaults), falling back to per-modifier `isDefault` flags. Sold-out
|
|
102
|
+
* modifiers are filtered out so the user doesn't start with an invalid pick.
|
|
103
|
+
*/
|
|
104
|
+
export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
|
|
105
|
+
const out: Record<string, string[]> = {};
|
|
106
|
+
for (const group of groups) {
|
|
107
|
+
if (group.max === 0) continue;
|
|
108
|
+
const fromDefaults = group.defaultModifierIds ?? [];
|
|
109
|
+
const fromIsDefault = group.modifiers
|
|
110
|
+
.filter((m) => m.isDefault && m.available)
|
|
111
|
+
.map((m) => m.id);
|
|
112
|
+
const merged =
|
|
113
|
+
fromDefaults.length > 0
|
|
114
|
+
? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
|
|
115
|
+
: fromIsDefault;
|
|
116
|
+
// Cap by max for SINGLE groups — defaults can't violate selection rules.
|
|
117
|
+
const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
|
|
118
|
+
if (capped.length > 0) out[group.id] = capped;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Re-export the platform-maintained payment URL allowlist from the SDK.
|
|
2
2
|
//
|
|
3
3
|
// Why a re-export and not a direct import everywhere: keeping a stable
|
|
4
|
-
// `@/lib/safe-redirect` import path means future SDK API changes (e.g. an
|
|
4
|
+
// `@/core/lib/safe-redirect` import path means future SDK API changes (e.g. an
|
|
5
5
|
// `extraHosts` rename) only need editing here, and merchants don't have to
|
|
6
6
|
// touch every component that does payment redirects. It also leaves a clear
|
|
7
7
|
// place to plug in store-specific extras (custom self-hosted PSP) without
|
|
@@ -71,6 +71,9 @@ export function sanitizeProductHtml(html: string): string {
|
|
|
71
71
|
'colgroup',
|
|
72
72
|
'col',
|
|
73
73
|
'iframe',
|
|
74
|
+
// Self-hosted product videos (uploaded via the dashboard media library).
|
|
75
|
+
'video',
|
|
76
|
+
'source',
|
|
74
77
|
],
|
|
75
78
|
ALLOWED_ATTR: [
|
|
76
79
|
'href',
|
|
@@ -87,6 +90,15 @@ export function sanitizeProductHtml(html: string): string {
|
|
|
87
90
|
'allowfullscreen',
|
|
88
91
|
'frameborder',
|
|
89
92
|
'loading',
|
|
93
|
+
'title',
|
|
94
|
+
// <video> / <source> playback attributes.
|
|
95
|
+
'controls',
|
|
96
|
+
'poster',
|
|
97
|
+
'preload',
|
|
98
|
+
'muted',
|
|
99
|
+
'loop',
|
|
100
|
+
'playsinline',
|
|
101
|
+
'type',
|
|
90
102
|
],
|
|
91
103
|
ALLOW_DATA_ATTR: false,
|
|
92
104
|
});
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import DOMPurify from 'isomorphic-dompurify';
|
|
16
16
|
|
|
17
|
-
const DEFAULT_CONFIG
|
|
17
|
+
const DEFAULT_CONFIG = {
|
|
18
18
|
// Allow iframes for embedded videos/maps the merchant may include.
|
|
19
19
|
ADD_TAGS: ['iframe'],
|
|
20
20
|
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'target', 'rel'],
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
4
|
import type { Cart, CustomerProfile } from 'brainerce';
|
|
5
5
|
import { getCartTotals } from 'brainerce';
|
|
6
|
-
import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
|
|
7
|
-
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/lib/store-info';
|
|
8
|
-
import { checkAuthStatus, proxyLogout } from '@/lib/auth';
|
|
6
|
+
import { getClient, initClient, setStoredCartId } from '@/core/lib/brainerce';
|
|
7
|
+
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
8
|
+
import { checkAuthStatus, proxyLogout } from '@/core/lib/auth';
|
|
9
9
|
<% if (i18nEnabled) { %>
|
|
10
|
-
import { MessagesContext } from '@/lib/translations';
|
|
10
|
+
import { MessagesContext } from '@/core/lib/translations';
|
|
11
11
|
import { getMessages, defaultLocale } from '@/i18n';
|
|
12
12
|
<% } %>
|
|
13
13
|
|
|
@@ -43,7 +43,7 @@ function buildCsp(nonce: string): string {
|
|
|
43
43
|
"style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
|
|
44
44
|
"img-src 'self' data: blob: https:",
|
|
45
45
|
"font-src 'self' data:",
|
|
46
|
-
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://*.brainerce.com",
|
|
46
|
+
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://*.brainerce.com",
|
|
47
47
|
`connect-src 'self' ${brainerceApiOrigin()} https://api.brainerce.com https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il`,
|
|
48
48
|
"worker-src 'self' blob:",
|
|
49
49
|
// 'self' (not 'none') so iframe-based payment providers (e.g. Cardcom)
|
|
@@ -171,7 +171,7 @@ function buildCsp(nonce: string): string {
|
|
|
171
171
|
"style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
|
|
172
172
|
"img-src 'self' data: blob: https:",
|
|
173
173
|
"font-src 'self' data:",
|
|
174
|
-
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://*.brainerce.com",
|
|
174
|
+
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com https://www.youtube.com https://www.youtube-nocookie.com https://player.vimeo.com https://*.brainerce.com",
|
|
175
175
|
`connect-src 'self' ${brainerceApiOrigin()} https://api.brainerce.com https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il`,
|
|
176
176
|
"worker-src 'self' blob:",
|
|
177
177
|
// 'self' (not 'none') so iframe-based payment providers (e.g. Cardcom)
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
5
|
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import { cn } from '@/lib/utils';
|
|
7
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
10
|
|
|
11
11
|
interface CartBundleOfferCardProps {
|
|
12
12
|
offer: CartBundleOfferType;
|
|
@@ -33,7 +33,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
33
33
|
if (adding) return;
|
|
34
34
|
try {
|
|
35
35
|
setAdding(true);
|
|
36
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
36
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
37
37
|
const client = getClient();
|
|
38
38
|
// No variant selections in the default template — products with
|
|
39
39
|
// variants are best handled with a dedicated picker per offered item.
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
5
|
import type { CartItem as CartItemType } from 'brainerce';
|
|
6
6
|
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
7
|
-
import { getClient } from '@/lib/brainerce';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
10
|
-
import { LoadingSpinner } from '@/
|
|
11
|
-
import { cn } from '@/lib/utils';
|
|
7
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
10
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
11
|
+
import { cn } from '@/core/lib/utils';
|
|
12
12
|
|
|
13
13
|
interface CartItemProps {
|
|
14
14
|
item: CartItemType;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { useTranslations } from '@/lib/translations';
|
|
5
|
-
import { useCart } from '@/providers/store-provider';
|
|
6
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
7
|
-
import { cn } from '@/lib/utils';
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
6
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
8
|
|
|
9
9
|
interface CartSummaryProps {
|
|
10
10
|
className?: string;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
|
-
import Image from '
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
5
|
import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { getClient } from '@/lib/brainerce';
|
|
8
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
9
|
-
import { useTranslations } from '@/lib/translations';
|
|
10
|
-
import { cn } from '@/lib/utils';
|
|
7
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
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 CartUpgradeBannerProps {
|
|
13
13
|
suggestion: CartUpgradeSuggestion;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Link } from '@/core/lib/navigation';
|
|
4
|
+
import { useCartPage } from '@/core/hooks/use-cart-page';
|
|
5
|
+
import { CartItem } from '@/ui/cart/cart-item';
|
|
6
|
+
import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
|
|
7
|
+
import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
|
|
8
|
+
import { CartSummary } from '@/ui/cart/cart-summary';
|
|
9
|
+
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
10
|
+
import { CartNudges } from '@/ui/cart/cart-nudges';
|
|
11
|
+
import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
|
|
12
|
+
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
13
|
+
import { CartRecommendationSection } from '@/ui/product/recommendation-section';
|
|
14
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
15
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
16
|
+
|
|
17
|
+
export function CartView() {
|
|
18
|
+
const t = useTranslations('cart');
|
|
19
|
+
const tc = useTranslations('common');
|
|
20
|
+
const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
|
|
21
|
+
|
|
22
|
+
if (cartLoading) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
25
|
+
<LoadingSpinner size="lg" />
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Empty cart state
|
|
31
|
+
if (!cart || cart.items.length === 0) {
|
|
32
|
+
return (
|
|
33
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
34
|
+
<svg
|
|
35
|
+
className="text-muted-foreground mx-auto mb-4 h-16 w-16"
|
|
36
|
+
fill="none"
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
stroke="currentColor"
|
|
39
|
+
>
|
|
40
|
+
<path
|
|
41
|
+
strokeLinecap="round"
|
|
42
|
+
strokeLinejoin="round"
|
|
43
|
+
strokeWidth={1.5}
|
|
44
|
+
d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"
|
|
45
|
+
/>
|
|
46
|
+
</svg>
|
|
47
|
+
<h1 className="text-foreground text-2xl font-bold">{t('emptyTitle')}</h1>
|
|
48
|
+
<p className="text-muted-foreground mt-2">{t('emptySubtitle')}</p>
|
|
49
|
+
<Link
|
|
50
|
+
href="/products"
|
|
51
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
52
|
+
>
|
|
53
|
+
{tc('continueShopping')}
|
|
54
|
+
</Link>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
61
|
+
<h1 className="text-foreground mb-6 text-2xl font-bold">
|
|
62
|
+
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
63
|
+
</h1>
|
|
64
|
+
|
|
65
|
+
{/* Reservation countdown */}
|
|
66
|
+
{cart.reservation?.hasReservation && (
|
|
67
|
+
<ReservationCountdown reservation={cart.reservation} className="mb-6" />
|
|
68
|
+
)}
|
|
69
|
+
|
|
70
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
71
|
+
{/* Cart Items */}
|
|
72
|
+
<div className="lg:col-span-2">
|
|
73
|
+
{/* Nudges */}
|
|
74
|
+
{cart.nudges && cart.nudges.length > 0 && (
|
|
75
|
+
<CartNudges nudges={cart.nudges} className="mb-4" />
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
{/* Cart items */}
|
|
79
|
+
<div>
|
|
80
|
+
{cart.items.map((item) => (
|
|
81
|
+
<div key={item.id}>
|
|
82
|
+
<CartItem item={item} onUpdate={refreshCart} />
|
|
83
|
+
{upgrades?.upgrades?.[item.productId] && (
|
|
84
|
+
<CartUpgradeBanner
|
|
85
|
+
suggestion={upgrades.upgrades[item.productId]}
|
|
86
|
+
cartItem={item}
|
|
87
|
+
onUpgrade={refreshCart}
|
|
88
|
+
className="mb-2 ms-24"
|
|
89
|
+
/>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* Bundle offers */}
|
|
96
|
+
{bundles?.bundles && bundles.bundles.length > 0 && (
|
|
97
|
+
<div className="mt-6 space-y-3">
|
|
98
|
+
<h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
|
|
99
|
+
{bundles.bundles.map((offer) => (
|
|
100
|
+
<CartBundleOfferCard
|
|
101
|
+
key={offer.id}
|
|
102
|
+
offer={offer}
|
|
103
|
+
cartId={cart.id}
|
|
104
|
+
onAdd={refreshCart}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Coupon input */}
|
|
111
|
+
<div className="border-border mt-6 border-t pt-4">
|
|
112
|
+
<CouponInput cart={cart} onUpdate={refreshCart} />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{/* Summary sidebar */}
|
|
117
|
+
<div className="lg:col-span-1">
|
|
118
|
+
<div className="bg-muted/50 border-border sticky top-24 rounded-lg border p-6">
|
|
119
|
+
<FreeShippingBar className="mb-4" />
|
|
120
|
+
<CartSummary />
|
|
121
|
+
|
|
122
|
+
<Link
|
|
123
|
+
href="/checkout"
|
|
124
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex w-full items-center justify-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
|
|
125
|
+
>
|
|
126
|
+
{t('proceedToCheckout')}
|
|
127
|
+
</Link>
|
|
128
|
+
|
|
129
|
+
<Link
|
|
130
|
+
href="/products"
|
|
131
|
+
className="text-muted-foreground hover:text-foreground mt-3 inline-flex w-full items-center justify-center px-6 py-2 text-sm transition-colors"
|
|
132
|
+
>
|
|
133
|
+
{tc('continueShopping')}
|
|
134
|
+
</Link>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{/* Cross-sell recommendations */}
|
|
140
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
141
|
+
<CartRecommendationSection
|
|
142
|
+
title={t('youMightAlsoNeed')}
|
|
143
|
+
items={cartRecs.recommendations}
|
|
144
|
+
className="mt-10"
|
|
145
|
+
/>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
4
|
import type { Cart } from 'brainerce';
|
|
5
|
-
import { getClient } from '@/lib/brainerce';
|
|
6
|
-
import { useTranslations } from '@/lib/translations';
|
|
7
|
-
import { LoadingSpinner } from '@/
|
|
8
|
-
import { cn } from '@/lib/utils';
|
|
5
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
7
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
8
|
+
import { cn } from '@/core/lib/utils';
|
|
9
9
|
|
|
10
10
|
interface CouponInputProps {
|
|
11
11
|
cart: Cart;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { useStoreInfo, useCart } from '@/providers/store-provider';
|
|
5
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
6
|
-
import { useTranslations } from '@/lib/translations';
|
|
7
|
-
import { cn } from '@/lib/utils';
|
|
4
|
+
import { useStoreInfo, useCart } from '@/core/providers/store-provider';
|
|
5
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
6
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
7
|
+
import { cn } from '@/core/lib/utils';
|
|
8
8
|
|
|
9
9
|
interface FreeShippingBarProps {
|
|
10
10
|
className?: string;
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from 'react';
|
|
4
4
|
import type { ReservationInfo } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/lib/translations';
|
|
6
|
-
import { cn } from '@/lib/utils';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
7
|
|
|
8
8
|
interface ReservationCountdownProps {
|
|
9
9
|
reservation: ReservationInfo;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { DiscountBanner } from 'brainerce';
|
|
4
|
+
|
|
5
|
+
/** Store-wide discount banner strip shown at the very top of the homepage. */
|
|
6
|
+
export function DiscountBannerStrip({ banners }: { banners: DiscountBanner[] }) {
|
|
7
|
+
if (banners.length === 0) return null;
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="bg-primary text-primary-foreground">
|
|
11
|
+
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 lg:px-8">
|
|
12
|
+
<div className="flex items-center justify-center gap-4 overflow-x-auto text-sm font-medium">
|
|
13
|
+
{banners.map((banner) => (
|
|
14
|
+
<span key={banner.ruleId}>{banner.text}</span>
|
|
15
|
+
))}
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Product } from 'brainerce';
|
|
4
|
+
import { Link } from '@/core/lib/navigation';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { ProductGrid } from '@/ui/product/product-grid';
|
|
7
|
+
|
|
8
|
+
/** Homepage featured-products grid with a "view all" link. */
|
|
9
|
+
export function FeaturedProductsSection({ products }: { products: Product[] }) {
|
|
10
|
+
const t = useTranslations('home');
|
|
11
|
+
const tc = useTranslations('common');
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
|
|
15
|
+
<div className="mb-8 flex items-center justify-between">
|
|
16
|
+
<h2 className="text-foreground text-2xl font-bold">{t('featuredProducts')}</h2>
|
|
17
|
+
<Link href="/products" className="text-primary text-sm font-medium hover:underline">
|
|
18
|
+
{tc('viewAll')}
|
|
19
|
+
</Link>
|
|
20
|
+
</div>
|
|
21
|
+
<ProductGrid products={products} />
|
|
22
|
+
</section>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Link } from '@/core/lib/navigation';
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
|
|
6
|
+
/** Homepage hero: welcome headline, subtitle, and shop-now CTA. */
|
|
7
|
+
export function HeroSection({ storeName }: { storeName?: string | null }) {
|
|
8
|
+
const t = useTranslations('home');
|
|
9
|
+
const tc = useTranslations('common');
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<section className="bg-muted">
|
|
13
|
+
<div className="mx-auto max-w-7xl px-4 py-20 text-center sm:px-6 lg:px-8">
|
|
14
|
+
<h1 className="text-foreground text-4xl font-bold tracking-tight sm:text-5xl">
|
|
15
|
+
{t('welcomeTo')} {storeName || tc('store')}
|
|
16
|
+
</h1>
|
|
17
|
+
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
|
18
|
+
{t('heroSubtitle')}
|
|
19
|
+
</p>
|
|
20
|
+
<Link
|
|
21
|
+
href="/products"
|
|
22
|
+
className="bg-primary text-primary-foreground mt-8 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
23
|
+
>
|
|
24
|
+
{tc('shopNow')}
|
|
25
|
+
</Link>
|
|
26
|
+
</div>
|
|
27
|
+
</section>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useStoreInfo } from '@/core/providers/store-provider';
|
|
4
|
+
import { useHomeData } from '@/core/hooks/use-home-data';
|
|
5
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
6
|
+
import { DiscountBannerStrip } from './discount-banner-strip';
|
|
7
|
+
import { HeroSection } from './hero-section';
|
|
8
|
+
import { FeaturedProductsSection } from './featured-products-section';
|
|
9
|
+
|
|
10
|
+
export function HomeClient() {
|
|
11
|
+
const { storeInfo, loading: storeLoading } = useStoreInfo();
|
|
12
|
+
const { products, banners, loading } = useHomeData();
|
|
13
|
+
|
|
14
|
+
if (storeLoading || loading) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
17
|
+
<LoadingSpinner size="lg" />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div>
|
|
24
|
+
<DiscountBannerStrip banners={banners} />
|
|
25
|
+
<HeroSection storeName={storeInfo?.name} />
|
|
26
|
+
<FeaturedProductsSection products={products} />
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
|
|
21
21
|
import * as React from 'react';
|
|
22
22
|
import type { Content } from 'brainerce';
|
|
23
|
-
import { useTranslations } from '@/lib/translations';
|
|
24
|
-
import { sanitizeHtml } from '@/lib/sanitize';
|
|
23
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
24
|
+
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
25
25
|
|
|
26
26
|
interface FaqSectionProps {
|
|
27
27
|
/** Pre-fetched FAQ row. `null` renders the empty-state fallback. */
|
|
@@ -13,9 +13,9 @@
|
|
|
13
13
|
* the cart icon's icon-only treatment; the icon + aria-label keep it accessible.
|
|
14
14
|
*/
|
|
15
15
|
import * as React from 'react';
|
|
16
|
-
import { Link } from '@/lib/navigation';
|
|
17
|
-
import { useAuth } from '@/providers/store-provider';
|
|
18
|
-
import { useTranslations } from '@/lib/translations';
|
|
16
|
+
import { Link } from '@/core/lib/navigation';
|
|
17
|
+
import { useAuth } from '@/core/providers/store-provider';
|
|
18
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
19
19
|
|
|
20
20
|
const UserIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
|
21
21
|
<svg
|