create-brainerce-store 1.34.1 → 1.34.3
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 +1 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +4 -1
- package/templates/nextjs/base/scripts/fetch-store-info.mjs +10 -4
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +4 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +4 -1
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +66 -1
- package/templates/nextjs/base/src/components/products/allergen-chips.tsx +78 -0
- package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +242 -0
- package/templates/nextjs/base/src/lib/auth.ts +6 -1
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +8 -3
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.34.
|
|
34
|
+
version: "1.34.3",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/package.json
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
# Brainerce
|
|
1
|
+
# Brainerce Sales Channel — preferred env var name.
|
|
2
|
+
NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID=<%= connectionId %>
|
|
3
|
+
# Legacy alias kept for backwards compatibility — both are accepted by the SDK.
|
|
4
|
+
# @deprecated will be removed when SDK 2.0 ships.
|
|
2
5
|
NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=<%= connectionId %>
|
|
3
6
|
|
|
4
7
|
# Store info (pre-fetched during setup to avoid flash on first load)
|
|
@@ -14,7 +14,7 @@ const envPath = join(process.cwd(), '.env.local');
|
|
|
14
14
|
|
|
15
15
|
if (!existsSync(envPath)) {
|
|
16
16
|
console.error(
|
|
17
|
-
'❌ .env.local not found. Create it first with
|
|
17
|
+
'❌ .env.local not found. Create it first with NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID set.'
|
|
18
18
|
);
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
@@ -34,18 +34,24 @@ function setVar(content, key, value) {
|
|
|
34
34
|
return content.trimEnd() + `\n${key}=${value}\n`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
// Read either env var name. The new one is preferred; the old one is a soft
|
|
38
|
+
// alias kept for backwards compatibility — the SDK accepts both.
|
|
39
|
+
const connectionId =
|
|
40
|
+
getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID') ||
|
|
41
|
+
getVar(envContent, 'NEXT_PUBLIC_BRAINERCE_CONNECTION_ID');
|
|
38
42
|
const apiUrl = (getVar(envContent, 'BRAINERCE_API_URL') || 'https://api.brainerce.com').replace(
|
|
39
43
|
/\/$/,
|
|
40
44
|
''
|
|
41
45
|
);
|
|
42
46
|
|
|
43
47
|
if (!connectionId) {
|
|
44
|
-
console.error(
|
|
48
|
+
console.error(
|
|
49
|
+
'❌ NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID is not set in .env.local'
|
|
50
|
+
);
|
|
45
51
|
process.exit(1);
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
console.log(`Fetching store info for
|
|
54
|
+
console.log(`Fetching store info for sales channel: ${connectionId} ...`);
|
|
49
55
|
|
|
50
56
|
let storeInfo;
|
|
51
57
|
try {
|
|
@@ -6,7 +6,10 @@ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com
|
|
|
6
6
|
''
|
|
7
7
|
);
|
|
8
8
|
|
|
9
|
-
const CONNECTION_ID =
|
|
9
|
+
const CONNECTION_ID =
|
|
10
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
|
|
11
|
+
process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
|
|
12
|
+
'';
|
|
10
13
|
|
|
11
14
|
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
12
15
|
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
@@ -8,7 +8,10 @@ const BACKEND_URL = (process.env.BRAINERCE_API_URL || 'https://api.brainerce.com
|
|
|
8
8
|
''
|
|
9
9
|
);
|
|
10
10
|
|
|
11
|
-
const CONNECTION_ID =
|
|
11
|
+
const CONNECTION_ID =
|
|
12
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
|
|
13
|
+
process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
|
|
14
|
+
'';
|
|
12
15
|
|
|
13
16
|
const RESET_TOKEN_COOKIE = 'brainerce_reset_token';
|
|
14
17
|
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ProductImage,
|
|
9
9
|
ProductMetafield,
|
|
10
10
|
DownloadFile,
|
|
11
|
+
ModifierGroup,
|
|
11
12
|
} from 'brainerce';
|
|
12
13
|
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
13
14
|
import { useCart } from '@/providers/store-provider';
|
|
@@ -22,6 +23,12 @@ import {
|
|
|
22
23
|
validateCustomization,
|
|
23
24
|
type CustomizationValues,
|
|
24
25
|
} from '@/components/products/customization-fields';
|
|
26
|
+
import {
|
|
27
|
+
ModifierGroupSelector,
|
|
28
|
+
buildInitialSelections,
|
|
29
|
+
toModifierSelections,
|
|
30
|
+
validateSelections,
|
|
31
|
+
} from '@/components/products/modifier-group-selector';
|
|
25
32
|
import { useTranslations } from '@/lib/translations';
|
|
26
33
|
import { cn } from '@/lib/utils';
|
|
27
34
|
import { sanitizeProductHtml } from '@/lib/sanitize-html';
|
|
@@ -138,6 +145,16 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
138
145
|
});
|
|
139
146
|
const [customizationErrors, setCustomizationErrors] = useState<Record<string, string>>({});
|
|
140
147
|
|
|
148
|
+
// Modifier groups (PRD §8.4) — only present on restaurant / build-your-own products.
|
|
149
|
+
const modifierGroups: ModifierGroup[] = useMemo(
|
|
150
|
+
() => (product as Product & { modifierGroups?: ModifierGroup[] }).modifierGroups ?? [],
|
|
151
|
+
[product]
|
|
152
|
+
);
|
|
153
|
+
const [modifierSelections, setModifierSelections] = useState<Record<string, string[]>>(() =>
|
|
154
|
+
buildInitialSelections(modifierGroups)
|
|
155
|
+
);
|
|
156
|
+
const [modifierError, setModifierError] = useState<string | null>(null);
|
|
157
|
+
|
|
141
158
|
// Images list - switch main image when variant changes
|
|
142
159
|
const images: ProductImage[] = useMemo(() => {
|
|
143
160
|
return product?.images || [];
|
|
@@ -227,6 +244,24 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
227
244
|
}
|
|
228
245
|
setCustomizationErrors({});
|
|
229
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
|
+
|
|
230
265
|
try {
|
|
231
266
|
setAddingToCart(true);
|
|
232
267
|
const { getClient } = await import('@/lib/brainerce');
|
|
@@ -239,12 +274,20 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
239
274
|
customizationFields.length > 0 && Object.keys(customizationValues).length > 0
|
|
240
275
|
? customizationValues
|
|
241
276
|
: undefined,
|
|
277
|
+
...(selections && selections.length > 0 ? { selections } : {}),
|
|
242
278
|
});
|
|
243
279
|
await refreshCart();
|
|
244
280
|
setAddedMessage(true);
|
|
245
281
|
setTimeout(() => setAddedMessage(false), 2000);
|
|
246
282
|
} catch (err) {
|
|
247
|
-
|
|
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
|
+
}
|
|
248
291
|
} finally {
|
|
249
292
|
setAddingToCart(false);
|
|
250
293
|
}
|
|
@@ -439,6 +482,28 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
439
482
|
/>
|
|
440
483
|
)}
|
|
441
484
|
|
|
485
|
+
{/* Modifier groups (toppings, sauce, bread type, …) */}
|
|
486
|
+
{modifierGroups.length > 0 && (
|
|
487
|
+
<div className="space-y-4">
|
|
488
|
+
{modifierGroups.map((group) => (
|
|
489
|
+
<ModifierGroupSelector
|
|
490
|
+
key={group.attachmentId ?? group.id}
|
|
491
|
+
group={group}
|
|
492
|
+
value={modifierSelections[group.id] ?? []}
|
|
493
|
+
onChange={(next) =>
|
|
494
|
+
setModifierSelections((prev) => ({ ...prev, [group.id]: next }))
|
|
495
|
+
}
|
|
496
|
+
disabled={addingToCart}
|
|
497
|
+
/>
|
|
498
|
+
))}
|
|
499
|
+
{modifierError && (
|
|
500
|
+
<p className="text-destructive text-sm" role="alert">
|
|
501
|
+
{modifierError}
|
|
502
|
+
</p>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
)}
|
|
506
|
+
|
|
442
507
|
{/* Quantity + Add to Cart */}
|
|
443
508
|
<div className="flex items-center gap-4">
|
|
444
509
|
<div className="border-border flex items-center rounded border">
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <AllergenChips> — informational allergen markers for storefront PDPs.
|
|
5
|
+
* Pure presentation; no interactivity. Pairs with `<ModifierGroupSelector>`
|
|
6
|
+
* but works standalone next to a product as well.
|
|
7
|
+
*
|
|
8
|
+
* Allergens are descriptive only: the system surfaces them but does not
|
|
9
|
+
* auto-block purchase. Customer-side allergen filtering is a deferred SDK
|
|
10
|
+
* feature (PRD §16).
|
|
11
|
+
*
|
|
12
|
+
* Icon resolution (PRD §15 Q4): when `allergen.iconUrl` is set, use it.
|
|
13
|
+
* Otherwise fall back to a built-in emoji keyed by `allergen.code` for the
|
|
14
|
+
* 8 common allergens listed in PRD §5.7. Stores that want branded icons
|
|
15
|
+
* upload via `iconUrl` per allergen; the rest get a sensible default
|
|
16
|
+
* without any setup.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Allergen } from 'brainerce';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Default emoji per canonical allergen code. The schema's @@unique on
|
|
23
|
+
* `(accountId, storeId, code)` means a store may have at most one of each;
|
|
24
|
+
* any code not in this map renders without an icon (the chip still shows
|
|
25
|
+
* the localized name, so the customer is informed regardless).
|
|
26
|
+
*/
|
|
27
|
+
const DEFAULT_EMOJI_BY_CODE: Record<string, string> = {
|
|
28
|
+
gluten: '🌾',
|
|
29
|
+
dairy: '🥛',
|
|
30
|
+
nuts: '🥜',
|
|
31
|
+
eggs: '🥚',
|
|
32
|
+
soy: '🫘',
|
|
33
|
+
fish: '🐟',
|
|
34
|
+
shellfish: '🦐',
|
|
35
|
+
sesame: '🌱',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
interface AllergenChipsProps {
|
|
39
|
+
allergens: Allergen[];
|
|
40
|
+
/**
|
|
41
|
+
* When the storefront has more allergen icons than fit nicely, condense
|
|
42
|
+
* to "Contains: X, Y, +N more" instead of rendering every chip.
|
|
43
|
+
*/
|
|
44
|
+
maxVisible?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function AllergenChips({ allergens, maxVisible = 6 }: AllergenChipsProps) {
|
|
48
|
+
if (!allergens || allergens.length === 0) return null;
|
|
49
|
+
|
|
50
|
+
const visible = allergens.slice(0, maxVisible);
|
|
51
|
+
const overflow = Math.max(0, allergens.length - visible.length);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<span className="allergen-chips" role="list" aria-label="Contains">
|
|
55
|
+
{visible.map((allergen) => {
|
|
56
|
+
const fallbackEmoji = DEFAULT_EMOJI_BY_CODE[allergen.code];
|
|
57
|
+
return (
|
|
58
|
+
<span key={allergen.id} className="allergen-chip" role="listitem" title={allergen.name}>
|
|
59
|
+
{allergen.iconUrl ? (
|
|
60
|
+
<img
|
|
61
|
+
src={allergen.iconUrl}
|
|
62
|
+
alt=""
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
className="allergen-chip__icon"
|
|
65
|
+
/>
|
|
66
|
+
) : fallbackEmoji ? (
|
|
67
|
+
<span aria-hidden="true" className="allergen-chip__emoji">
|
|
68
|
+
{fallbackEmoji}
|
|
69
|
+
</span>
|
|
70
|
+
) : null}
|
|
71
|
+
<span className="allergen-chip__name">{allergen.name}</span>
|
|
72
|
+
</span>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
{overflow > 0 && <span className="allergen-chip allergen-chip--more">+{overflow} more</span>}
|
|
76
|
+
</span>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <ModifierGroupSelector> — reference React component for storefront authors
|
|
5
|
+
* (PRD §8.4).
|
|
6
|
+
*
|
|
7
|
+
* Drop-in renderer for a single modifier group (toppings, sauce, bread type, …).
|
|
8
|
+
* Storefront authors are encouraged to copy this file and tailor the styling /
|
|
9
|
+
* markup to their site — the data contract is what matters, not the markup.
|
|
10
|
+
*
|
|
11
|
+
* Behavior pinned by the contract:
|
|
12
|
+
* - SINGLE → renders as <input type="radio">, max picks = 1
|
|
13
|
+
* - MULTIPLE → renders as <input type="checkbox">, picks bounded by min..max
|
|
14
|
+
* - effectiveMax === 0 → group is hidden entirely (variant-disable convention)
|
|
15
|
+
* - available === false → modifier is disabled with "Sold out" badge
|
|
16
|
+
* - free counter shows "{used} of {freeQuantity} free" when freeQuantity > 0
|
|
17
|
+
* - defaultModifierIds + isDefault drive first-render checked state
|
|
18
|
+
* - client-side guards mirror min/max (UX); the SERVER is authoritative — wire
|
|
19
|
+
* `MODIFIER_VALIDATION_FAILED` errors back to the user when add-to-cart fails
|
|
20
|
+
*
|
|
21
|
+
* The price-delta side: this component does NOT compute totals. The server
|
|
22
|
+
* runs the free-allocation policy and returns the line's `unitPrice` /
|
|
23
|
+
* `modifiers[]` / `modifiersTotal` snapshot — render those after add-to-cart.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Modifier, ModifierGroup, ModifierSelection } from 'brainerce';
|
|
27
|
+
import { AllergenChips } from './allergen-chips';
|
|
28
|
+
|
|
29
|
+
interface ModifierGroupSelectorProps {
|
|
30
|
+
/**
|
|
31
|
+
* Effective group as returned by `GET /products/:id` (overrides already
|
|
32
|
+
* applied — `min`, `max`, `freeQuantity`, etc. are the values the server
|
|
33
|
+
* will validate against for the active variant).
|
|
34
|
+
*/
|
|
35
|
+
group: ModifierGroup;
|
|
36
|
+
/** Currently-selected modifier IDs in click-order. */
|
|
37
|
+
value: string[];
|
|
38
|
+
onChange: (next: string[]) => void;
|
|
39
|
+
/** Disable all controls (e.g., while add-to-cart is in flight). */
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ModifierGroupSelector({
|
|
44
|
+
group,
|
|
45
|
+
value,
|
|
46
|
+
onChange,
|
|
47
|
+
disabled = false,
|
|
48
|
+
}: ModifierGroupSelectorProps) {
|
|
49
|
+
// PRD §7.2.2: variant-disable convention — group with effectiveMax=0 is hidden.
|
|
50
|
+
if (group.max === 0) return null;
|
|
51
|
+
|
|
52
|
+
const isSingle = group.selectionType === 'SINGLE';
|
|
53
|
+
// Client-side bound — server is authoritative.
|
|
54
|
+
const effectiveMax = isSingle ? 1 : (group.max ?? Infinity);
|
|
55
|
+
|
|
56
|
+
const handleToggle = (modifierId: string, checked: boolean) => {
|
|
57
|
+
if (disabled) return;
|
|
58
|
+
|
|
59
|
+
if (isSingle) {
|
|
60
|
+
// Radio behavior: replace the selection.
|
|
61
|
+
onChange(checked ? [modifierId] : []);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (checked) {
|
|
66
|
+
if (value.length >= effectiveMax) return; // would exceed max — ignore the click
|
|
67
|
+
if (value.includes(modifierId)) return;
|
|
68
|
+
onChange([...value, modifierId]);
|
|
69
|
+
} else {
|
|
70
|
+
onChange(value.filter((id) => id !== modifierId));
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const sortedModifiers = [...group.modifiers].sort((a, b) => a.position - b.position);
|
|
75
|
+
|
|
76
|
+
// Counter: how many of the picked items are within the "free" window.
|
|
77
|
+
const usedFreeSlots = Math.min(value.length, group.freeQuantity);
|
|
78
|
+
const showFreeCounter = group.freeQuantity > 0;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<fieldset className="modifier-group">
|
|
82
|
+
<legend className="modifier-group__legend">
|
|
83
|
+
<span className="modifier-group__name">{group.name}</span>
|
|
84
|
+
{group.required && <span className="modifier-group__required"> *</span>}
|
|
85
|
+
<span className="modifier-group__rules">{ruleSummary(group)}</span>
|
|
86
|
+
</legend>
|
|
87
|
+
|
|
88
|
+
{group.description && <p className="modifier-group__description">{group.description}</p>}
|
|
89
|
+
|
|
90
|
+
{showFreeCounter && (
|
|
91
|
+
<p className="modifier-group__counter" aria-live="polite">
|
|
92
|
+
{usedFreeSlots} of {group.freeQuantity} free
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
<ul className="modifier-group__options">
|
|
97
|
+
{sortedModifiers.map((modifier) => {
|
|
98
|
+
const isChecked = value.includes(modifier.id);
|
|
99
|
+
const isAtCap = !isSingle && !isChecked && value.length >= effectiveMax;
|
|
100
|
+
const isDisabled = disabled || !modifier.available || isAtCap;
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<li key={modifier.id} className="modifier-option">
|
|
104
|
+
<label className="modifier-option__label">
|
|
105
|
+
<input
|
|
106
|
+
type={isSingle ? 'radio' : 'checkbox'}
|
|
107
|
+
name={`modifier-group-${group.id}`}
|
|
108
|
+
value={modifier.id}
|
|
109
|
+
checked={isChecked}
|
|
110
|
+
disabled={isDisabled}
|
|
111
|
+
onChange={(e) => handleToggle(modifier.id, e.target.checked)}
|
|
112
|
+
className="modifier-option__input"
|
|
113
|
+
/>
|
|
114
|
+
<span className="modifier-option__name">{modifier.name}</span>
|
|
115
|
+
<ModifierPriceLabel modifier={modifier} />
|
|
116
|
+
{!modifier.available && (
|
|
117
|
+
<span
|
|
118
|
+
className="modifier-option__badge modifier-option__badge--soldout"
|
|
119
|
+
aria-label="Sold out"
|
|
120
|
+
>
|
|
121
|
+
Sold out
|
|
122
|
+
</span>
|
|
123
|
+
)}
|
|
124
|
+
{modifier.allergens && modifier.allergens.length > 0 && (
|
|
125
|
+
<AllergenChips allergens={modifier.allergens} />
|
|
126
|
+
)}
|
|
127
|
+
</label>
|
|
128
|
+
{modifier.description && (
|
|
129
|
+
<p className="modifier-option__description">{modifier.description}</p>
|
|
130
|
+
)}
|
|
131
|
+
</li>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</ul>
|
|
135
|
+
</fieldset>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Renders the modifier's price contribution next to its name.
|
|
141
|
+
*
|
|
142
|
+
* Negative deltas display with a leading minus sign (downsell modifiers per
|
|
143
|
+
* PRD §6.3.4); zero-delta modifiers render nothing — silence is the right
|
|
144
|
+
* affordance for a free option.
|
|
145
|
+
*/
|
|
146
|
+
function ModifierPriceLabel({ modifier }: { modifier: Modifier }) {
|
|
147
|
+
const delta = parseFloat(modifier.priceDelta);
|
|
148
|
+
if (Number.isNaN(delta) || delta === 0) return null;
|
|
149
|
+
const sign = delta > 0 ? '+' : '';
|
|
150
|
+
return (
|
|
151
|
+
<span className="modifier-option__price">
|
|
152
|
+
{sign}
|
|
153
|
+
{modifier.priceDelta}
|
|
154
|
+
</span>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function ruleSummary(group: ModifierGroup): string {
|
|
159
|
+
const parts: string[] = [];
|
|
160
|
+
if (group.selectionType === 'SINGLE') {
|
|
161
|
+
parts.push('Pick one');
|
|
162
|
+
} else if (group.max != null) {
|
|
163
|
+
if (group.min === 0) parts.push(`Up to ${group.max}`);
|
|
164
|
+
else if (group.min === group.max) parts.push(`Pick exactly ${group.min}`);
|
|
165
|
+
else parts.push(`${group.min}–${group.max}`);
|
|
166
|
+
} else if (group.min > 0) {
|
|
167
|
+
parts.push(`At least ${group.min}`);
|
|
168
|
+
}
|
|
169
|
+
return parts.length ? `(${parts.join(', ')})` : '';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Lightweight client-side validation that mirrors the server's checks. Use
|
|
174
|
+
* this to gate the add-to-cart button — but always treat the server's 400
|
|
175
|
+
* (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
|
|
176
|
+
*/
|
|
177
|
+
export function validateSelections(
|
|
178
|
+
groups: ModifierGroup[],
|
|
179
|
+
selections: Record<string, string[]>
|
|
180
|
+
): string | null {
|
|
181
|
+
for (const group of groups) {
|
|
182
|
+
if (group.max === 0) continue; // disabled-for-variant — skip
|
|
183
|
+
const picks = selections[group.id] ?? [];
|
|
184
|
+
if (group.required && picks.length === 0) {
|
|
185
|
+
return `${group.name} is required`;
|
|
186
|
+
}
|
|
187
|
+
if (picks.length < group.min) {
|
|
188
|
+
return `Pick at least ${group.min} from ${group.name}`;
|
|
189
|
+
}
|
|
190
|
+
if (group.max != null && picks.length > group.max) {
|
|
191
|
+
return `Pick at most ${group.max} from ${group.name}`;
|
|
192
|
+
}
|
|
193
|
+
if (group.selectionType === 'SINGLE' && picks.length > 1) {
|
|
194
|
+
return `Only one option allowed in ${group.name}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Adapter: convert the local Record<groupId, modifierIds> shape used by this
|
|
202
|
+
* component into the wire-format `ModifierSelection[]` array the SDK expects
|
|
203
|
+
* on `addToCart` / `updateCartItem`.
|
|
204
|
+
*/
|
|
205
|
+
export function toModifierSelections(
|
|
206
|
+
groups: ModifierGroup[],
|
|
207
|
+
selections: Record<string, string[]>
|
|
208
|
+
): ModifierSelection[] {
|
|
209
|
+
const out: ModifierSelection[] = [];
|
|
210
|
+
for (const group of groups) {
|
|
211
|
+
if (group.max === 0) continue;
|
|
212
|
+
const picks = selections[group.id] ?? [];
|
|
213
|
+
if (picks.length === 0) continue;
|
|
214
|
+
out.push({ modifierGroupId: group.id, modifierIds: picks });
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Build the initial selection map from the group's defaults — call once on
|
|
221
|
+
* first render for each group. Honors `defaultModifierIds` first (per-attach
|
|
222
|
+
* defaults), falling back to per-modifier `isDefault` flags. Sold-out
|
|
223
|
+
* modifiers are filtered out so the user doesn't start with an invalid pick.
|
|
224
|
+
*/
|
|
225
|
+
export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
|
|
226
|
+
const out: Record<string, string[]> = {};
|
|
227
|
+
for (const group of groups) {
|
|
228
|
+
if (group.max === 0) continue;
|
|
229
|
+
const fromDefaults = group.defaultModifierIds ?? [];
|
|
230
|
+
const fromIsDefault = group.modifiers
|
|
231
|
+
.filter((m) => m.isDefault && m.available)
|
|
232
|
+
.map((m) => m.id);
|
|
233
|
+
const merged =
|
|
234
|
+
fromDefaults.length > 0
|
|
235
|
+
? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
|
|
236
|
+
: fromIsDefault;
|
|
237
|
+
// Cap by max for SINGLE groups — defaults can't violate selection rules.
|
|
238
|
+
const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
|
|
239
|
+
if (capped.length > 0) out[group.id] = capped;
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
* The token is managed server-side via httpOnly cookies — never exposed to JS.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Read either env var name. The new one is preferred; the old one is a soft
|
|
8
|
+
// alias kept for backwards compatibility — both are accepted by the SDK.
|
|
9
|
+
const CONNECTION_ID =
|
|
10
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
|
|
11
|
+
process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
|
|
12
|
+
'';
|
|
8
13
|
|
|
9
14
|
const CSRF_HEADERS: Record<string, string> = {
|
|
10
15
|
'Content-Type': 'application/json',
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { BrainerceClient } from 'brainerce';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Read either env var name. The new one is preferred; the old one is a soft
|
|
4
|
+
// alias kept for backwards compatibility — both are accepted by the SDK.
|
|
5
|
+
const SALES_CHANNEL_ID =
|
|
6
|
+
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
|
|
7
|
+
process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
|
|
8
|
+
'<%= connectionId %>';
|
|
4
9
|
|
|
5
10
|
// Singleton SDK client — routes through same-origin BFF proxy for httpOnly cookie auth
|
|
6
11
|
let clientInstance: BrainerceClient | null = null;
|
|
@@ -8,7 +13,7 @@ let clientInstance: BrainerceClient | null = null;
|
|
|
8
13
|
export function getClient(): BrainerceClient {
|
|
9
14
|
if (!clientInstance) {
|
|
10
15
|
clientInstance = new BrainerceClient({
|
|
11
|
-
|
|
16
|
+
salesChannelId: SALES_CHANNEL_ID,
|
|
12
17
|
baseUrl: '/api/store', // same-origin proxy handles auth via httpOnly cookie
|
|
13
18
|
proxyMode: true, // skip client-side token checks; proxy adds Authorization header
|
|
14
19
|
});
|
|
@@ -52,7 +57,7 @@ export function initClient(): BrainerceClient {
|
|
|
52
57
|
export function getServerClient(locale?: string): BrainerceClient {
|
|
53
58
|
const apiUrl = process.env.BRAINERCE_API_URL || 'https://api.brainerce.com';
|
|
54
59
|
const client = new BrainerceClient({
|
|
55
|
-
|
|
60
|
+
salesChannelId: SALES_CHANNEL_ID,
|
|
56
61
|
baseUrl: apiUrl,
|
|
57
62
|
origin: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
|
58
63
|
});
|