create-brainerce-store 1.68.0 → 1.71.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.
Files changed (32) hide show
  1. package/dist/index.js +22 -2
  2. package/messages/en.json +52 -2
  3. package/messages/he.json +52 -2
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
  6. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
  7. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  8. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  9. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  10. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  11. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  12. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  13. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  14. package/templates/nextjs/base/src/core/lib/auth.ts +162 -154
  15. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  16. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  17. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  18. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  19. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  20. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  21. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  22. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  23. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  24. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  25. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  26. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  27. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  28. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  29. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  30. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  31. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  32. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
@@ -0,0 +1,173 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Back-in-stock alert — "email me when this is back".
5
+ *
6
+ * ⛔ THIS IS NOT A NEWSLETTER SIGNUP. `stockAlerts.subscribe()` grants no
7
+ * marketing consent, creates no customer account, and the person receives
8
+ * exactly one message: the alert itself, about this item, carrying a link that
9
+ * stops it. That is why the button says "Notify me" and never "Subscribe" —
10
+ * and why a shopper who unsubscribed from the store's marketing can still use
11
+ * it. Never gate it on consent.
12
+ *
13
+ * ⛔ RENDER IT ONLY WHERE IT APPLIES. `canOfferStockAlert()` below is the whole
14
+ * gate. Requests for anything else are silently discarded server-side (the
15
+ * response is uniform on purpose, so it cannot be used to read a store's stock
16
+ * levels), which means a button in the wrong place looks like it worked and
17
+ * does nothing.
18
+ *
19
+ * ⛔ THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
20
+ * for a new request, a duplicate, a product id that does not exist, an item
21
+ * already in stock, and an address suppressed after a hard bounce. There is
22
+ * nothing to branch on — render one success state and stop.
23
+ *
24
+ * ⛔ DO NOT PROMISE TIMING. Availability is `total - reserved`, so an expiring
25
+ * cart briefly lifts a sold-out item above zero; the alert waits for stock to
26
+ * hold, then goes out in waves sized to the units that came back, oldest
27
+ * request first. A shopper can sit through a restock without hearing. "We'll
28
+ * email you when it's back" is true; "you'll be the first to know" is not.
29
+ *
30
+ * Rate limited to 5 requests / 60s per IP, 25 open alerts per address per store.
31
+ */
32
+
33
+ import { useMemo, useState } from 'react';
34
+ import type { InventoryInfo } from 'brainerce';
35
+ import { getClient } from '@/core/lib/brainerce';
36
+ import { useTranslations } from '@/core/lib/translations';
37
+
38
+ /**
39
+ * The gate. Four ways the answer is no, and every one of them matters:
40
+ *
41
+ * - the merchant switched the feature off for this storefront
42
+ * (`storeInfo.stockAlertsEnabled`, read once at boot);
43
+ * - the item is in stock, so there is nothing to wait for;
44
+ * - stock is not TRACKED — UNLIMITED never runs out, DISABLED is not for sale
45
+ * and no restock changes that;
46
+ * - the merchant allows backorders on it, so the storefront can already sell
47
+ * it while out of stock and an alert would tell someone to come back and do
48
+ * what they can already do.
49
+ */
50
+ export function canOfferStockAlert(
51
+ inventory: InventoryInfo | null | undefined,
52
+ stockAlertsEnabled: boolean | undefined
53
+ ): boolean {
54
+ if (stockAlertsEnabled === false) return false;
55
+ if (!inventory) return false;
56
+ if (inventory.trackingMode !== 'TRACKED') return false;
57
+ if (inventory.canPurchase) return false;
58
+ // Older backends omit the field entirely; absent means no backorder.
59
+ return (inventory.backorderMode ?? 'NONE') === 'NONE';
60
+ }
61
+
62
+ interface BackInStockFormProps {
63
+ productId: string;
64
+ /**
65
+ * ⛔ Pass the SELECTED variant on any product with variants. Without it the
66
+ * alert waits on the product as a whole, so a shopper who wanted the medium
67
+ * is mailed when the small returns and arrives to find their size still gone.
68
+ */
69
+ variantId?: string;
70
+ }
71
+
72
+ export function BackInStockForm({ productId, variantId }: BackInStockFormProps) {
73
+ const t = useTranslations('backInStock');
74
+ const [email, setEmail] = useState('');
75
+ const [honeypot, setHoneypot] = useState('');
76
+ const [loading, setLoading] = useState(false);
77
+ const [done, setDone] = useState(false);
78
+ const [error, setError] = useState<string | null>(null);
79
+
80
+ // Same source the newsletter form uses — <html lang> is set from the active
81
+ // locale, and passing it is what decides the language of the alert email.
82
+ // Omit it on a Hebrew storefront and the shopper gets English.
83
+ const locale = useMemo(() => {
84
+ if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
85
+ return undefined;
86
+ }, []);
87
+
88
+ async function handleSubmit(e: React.FormEvent) {
89
+ e.preventDefault();
90
+ if (loading || !email.trim()) return;
91
+
92
+ setLoading(true);
93
+ setError(null);
94
+ try {
95
+ await getClient().stockAlerts.subscribe({
96
+ email: email.trim(),
97
+ productId,
98
+ variantId,
99
+ locale,
100
+ honeypot,
101
+ });
102
+ setDone(true);
103
+ setEmail('');
104
+ } catch {
105
+ // A 429 lands here too — the copy stays generic rather than explaining
106
+ // the rate limit, which would only tell an abuser where the edge is.
107
+ setError(t('genericError'));
108
+ } finally {
109
+ setLoading(false);
110
+ }
111
+ }
112
+
113
+ if (done) {
114
+ return (
115
+ <div className="border-border space-y-1 rounded border p-4">
116
+ <p className="text-sm font-medium">{t('doneTitle')}</p>
117
+ <p className="text-sm opacity-70">{t('doneBody')}</p>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ return (
123
+ <form onSubmit={handleSubmit} className="border-border space-y-2 rounded border p-4">
124
+ <label htmlFor="back-in-stock-email" className="block text-sm font-medium">
125
+ {t('title')}
126
+ </label>
127
+ <p className="text-sm opacity-70">{t('subtitle')}</p>
128
+
129
+ <div className="flex flex-wrap gap-2">
130
+ <input
131
+ id="back-in-stock-email"
132
+ type="email"
133
+ required
134
+ autoComplete="email"
135
+ value={email}
136
+ onChange={(e) => setEmail(e.target.value)}
137
+ placeholder={t('placeholder')}
138
+ className="border-border min-w-0 flex-1 rounded border px-3 py-2 text-sm"
139
+ />
140
+ <button
141
+ type="submit"
142
+ disabled={loading}
143
+ className="bg-primary text-primary-foreground rounded px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60"
144
+ >
145
+ {loading ? t('submitting') : t('submit')}
146
+ </button>
147
+ </div>
148
+
149
+ {/*
150
+ Honeypot. Bots complete every text input; a human never sees this one, so
151
+ a non-empty value rejects the request server-side. Positioned off-screen
152
+ rather than `display:none` — some bots skip hidden inputs — and kept out
153
+ of the tab order and the accessibility tree.
154
+ */}
155
+ <input
156
+ type="text"
157
+ name="company_website"
158
+ tabIndex={-1}
159
+ autoComplete="off"
160
+ aria-hidden="true"
161
+ value={honeypot}
162
+ onChange={(e) => setHoneypot(e.target.value)}
163
+ style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
164
+ />
165
+
166
+ {error ? (
167
+ <p role="alert" className="text-sm text-red-600">
168
+ {error}
169
+ </p>
170
+ ) : null}
171
+ </form>
172
+ );
173
+ }