create-brainerce-store 1.79.0 → 1.80.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 CHANGED
@@ -31,8 +31,17 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.79.0",
34
+ version: "1.80.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
+ homepage: "https://brainerce.com",
37
+ repository: {
38
+ type: "git",
39
+ url: "https://github.com/brainerce/brainerce.git",
40
+ directory: "packages/create-brainerce-store"
41
+ },
42
+ bugs: {
43
+ url: "https://github.com/brainerce/brainerce/issues"
44
+ },
36
45
  bin: {
37
46
  "create-brainerce-store": "dist/index.js"
38
47
  },
@@ -315,7 +324,13 @@ var BRAINERCE_RUNTIME_DEPS = Object.freeze({
315
324
  // off an SDK-typed `Product`, so on an older SDK a scaffolded store fails
316
325
  // type-check at creation. Verified against the published 2.4.0 tarball: zero
317
326
  // files mention either field.
318
- brainerce: "^2.5.0",
327
+ //
328
+ // 2.7.0 is the first SDK with `marketing.getBenefit()` and
329
+ // `PublicNewsletterBenefitOffer`, and the scaffolded newsletter signup reads
330
+ // both to show the merchant's welcome offer beside the field. On an older
331
+ // SDK a scaffolded store fails type-check at creation, exactly like the KIT
332
+ // floor above — so this floor is load-bearing, not cosmetic.
333
+ brainerce: "^2.7.0",
319
334
  "isomorphic-dompurify": "^3.8.0"
320
335
  });
321
336
 
package/messages/en.json CHANGED
@@ -42,7 +42,7 @@
42
42
  "birthday": "Birthday",
43
43
  "birthdayMonth": "Month",
44
44
  "birthdayDay": "Day",
45
- "birthdayGiftNote": "Add your birthday and a gift arrives by email on the day.",
45
+ "birthdayGiftNote": "Add your birthday and a gift arrives by email on the day, as long as you are signed up for our emails.",
46
46
  "birthdayIncomplete": "Choose both a month and a day, or leave both empty.",
47
47
  "birthdayRequired": "Choose your birthday month and day to continue.",
48
48
  "birthdayPlaceholder": "Choose your birthday",
@@ -122,7 +122,7 @@
122
122
  "addSelectedToCart": "Add Selected to Cart",
123
123
  "totalPrice": "Total: {price}",
124
124
  "addingAll": "Adding...",
125
- "whatsInTheBox": "What's in the box",
125
+ "whatsInTheBox": "What's in the box",
126
126
  "by": "By"
127
127
  },
128
128
  "reviews": {
@@ -569,7 +569,9 @@
569
569
  "submitting": "Sending...",
570
570
  "checkEmailTitle": "Almost there — check your email",
571
571
  "checkEmailBody": "We've sent you a link to confirm. Look in your spam folder too; you won't get another copy for 24 hours.",
572
- "discountCode": "Your code: {code}",
572
+ "offerPercent": "{value}% off your first order",
573
+ "offerAmount": "{value} off your first order",
574
+ "offerByEmail": "Your personal code is in that email too. It is one per address.",
573
575
  "genericError": "Something went wrong. Please try again."
574
576
  },
575
577
  "loyalty": {
package/messages/he.json CHANGED
@@ -42,7 +42,7 @@
42
42
  "birthday": "יום הולדת",
43
43
  "birthdayMonth": "חודש",
44
44
  "birthdayDay": "יום",
45
- "birthdayGiftNote": "הוסיפו את יום ההולדת ותקבלו מתנה במייל ביום עצמו.",
45
+ "birthdayGiftNote": "הוסיפו את יום ההולדת ותקבלו מתנה במייל ביום עצמו, בתנאי שאתם רשומים לדיוור שלנו.",
46
46
  "birthdayIncomplete": "בחרו גם חודש וגם יום, או השאירו את שניהם ריקים.",
47
47
  "birthdayRequired": "בחרו את חודש ויום ההולדת כדי להמשיך.",
48
48
  "birthdayPlaceholder": "בחרו את יום ההולדת",
@@ -122,7 +122,7 @@
122
122
  "addSelectedToCart": "הוסף הנבחרים לעגלה",
123
123
  "totalPrice": "סה\"כ: {price}",
124
124
  "addingAll": "מוסיף...",
125
- "whatsInTheBox": "מה יש בקופסה",
125
+ "whatsInTheBox": "מה יש בקופסה",
126
126
  "by": "מאת"
127
127
  },
128
128
  "reviews": {
@@ -569,7 +569,9 @@
569
569
  "submitting": "שולח...",
570
570
  "checkEmailTitle": "כמעט סיימנו — בדקו את המייל",
571
571
  "checkEmailBody": "שלחנו לכם קישור לאישור. בדקו גם בתיקיית הספאם; עותק נוסף לא יישלח ב-24 השעות הקרובות.",
572
- "discountCode": "הקוד שלכם: {code}",
572
+ "offerPercent": "{value}% הנחה על ההזמנה הראשונה",
573
+ "offerAmount": "{value} הנחה על ההזמנה הראשונה",
574
+ "offerByEmail": "הקוד האישי שלכם נמצא באותו מייל. הטבה אחת לכל כתובת.",
573
575
  "genericError": "משהו השתבש. נסו שוב."
574
576
  },
575
577
  "loyalty": {
package/package.json CHANGED
@@ -1,7 +1,16 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.79.0",
3
+ "version": "1.80.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
+ "homepage": "https://brainerce.com",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/brainerce/brainerce.git",
9
+ "directory": "packages/create-brainerce-store"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/brainerce/brainerce/issues"
13
+ },
5
14
  "bin": {
6
15
  "create-brainerce-store": "dist/index.js"
7
16
  },
@@ -7,6 +7,7 @@ import { useTranslations } from '@/core/lib/translations';
7
7
  import { cn } from '@/core/lib/utils';
8
8
  import { birthMonthKey, toBirthdayNumber } from '@/core/lib/birthday';
9
9
  import { BirthdayPicker } from '@/components/shared/birthday-picker';
10
+ import { useStoreCapabilities } from '@/core/providers/store-provider';
10
11
 
11
12
  interface ProfileSectionProps {
12
13
  profile: CustomerProfile;
@@ -17,6 +18,9 @@ interface ProfileSectionProps {
17
18
  export function ProfileSection({ profile, onProfileUpdate, className }: ProfileSectionProps) {
18
19
  const t = useTranslations('account');
19
20
  const tc = useTranslations('common');
21
+ const { capabilities } = useStoreCapabilities();
22
+ /** Explicit `true` only — see the note on the same gate in `register-form.tsx`. */
23
+ const birthdayGiftOn = capabilities?.features.hasBirthdayRewards === true;
20
24
  const [editing, setEditing] = useState(false);
21
25
  const [saving, setSaving] = useState(false);
22
26
  const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
@@ -182,7 +186,9 @@ export function ProfileSection({ profile, onProfileUpdate, className }: ProfileS
182
186
  onChange={selectBirthday}
183
187
  triggerClassName="rounded-md"
184
188
  />
185
- <p className="text-muted-foreground mt-1 text-xs">{tc('birthdayGiftNote')}</p>
189
+ {birthdayGiftOn && (
190
+ <p className="text-muted-foreground mt-1 text-xs">{tc('birthdayGiftNote')}</p>
191
+ )}
186
192
  </div>
187
193
  <p className="text-muted-foreground truncate text-sm">{profile.email}</p>
188
194
  <div className="flex items-center gap-2">
@@ -9,6 +9,7 @@ import { useStoreInfo } from '@/core/providers/store-provider';
9
9
  import { toBirthdayNumber } from '@/core/lib/birthday';
10
10
  import { BirthdayPicker } from '@/components/shared/birthday-picker';
11
11
  import { readReferralCookie } from '@/core/lib/referral';
12
+ import { useStoreCapabilities } from '@/core/providers/store-provider';
12
13
 
13
14
  interface RegisterData {
14
15
  firstName: string;
@@ -66,6 +67,20 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
66
67
  const [privacyError, setPrivacyError] = useState(false);
67
68
  const [passwordError, setPasswordError] = useState<string | null>(null);
68
69
  const [acceptsMarketing, setAcceptsMarketing] = useState(false);
70
+ const { capabilities } = useStoreCapabilities();
71
+
72
+ /**
73
+ * Whether to promise a birthday gift beside the picker.
74
+ *
75
+ * The gate is inverted relative to `ReferralGreeting`, and deliberately so.
76
+ * There, an undecided capability must not suppress a real referral, so only
77
+ * an explicit `false` hides anything. Here the text makes a PROMISE, and a
78
+ * promise may only be printed on an explicit `true`: a store with birthday
79
+ * rewards switched off — which is the default — would otherwise tell every
80
+ * customer a gift is coming and send nothing, and `requireBirthday` can make
81
+ * handing over the date compulsory on top of that.
82
+ */
83
+ const birthdayGiftOn = capabilities?.features.hasBirthdayRewards === true;
69
84
  const [birthMonth, setBirthMonth] = useState('');
70
85
  const [birthDay, setBirthDay] = useState('');
71
86
  const [birthdayError, setBirthdayError] = useState<string | null>(null);
@@ -293,7 +308,9 @@ export function RegisterForm({ onSubmit, error, className }: RegisterFormProps)
293
308
  required={birthdayRequired}
294
309
  invalid={birthdayError !== null}
295
310
  />
296
- <p className="text-muted-foreground mt-1.5 text-xs">{tc('birthdayGiftNote')}</p>
311
+ {birthdayGiftOn && (
312
+ <p className="text-muted-foreground mt-1.5 text-xs">{tc('birthdayGiftNote')}</p>
313
+ )}
297
314
  {birthdayError && <p className="text-destructive mt-1 text-xs">{birthdayError}</p>}
298
315
  </div>
299
316
 
@@ -12,7 +12,7 @@ import { useStoreInfo } from '@/core/providers/store-provider';
12
12
  *
13
13
  * There is nothing to configure here and no environment variable to set. The
14
14
  * tag ids arrive inside `storeInfo.tracking`, which the backend resolves from
15
- * the merchant's connected marketplace apps: connecting the Google & YouTube
15
+ * the merchant's connected marketplace apps: connecting the Google
16
16
  * app runs GA4 discovery and the measurement id shows up on its own, likewise
17
17
  * the Meta and TikTok pixels. Connect an app in the dashboard and this file
18
18
  * starts loading its tag within a minute — no redeploy, no code change.
@@ -21,17 +21,26 @@
21
21
  * commonest reason a signup never becomes a subscriber, and the resend cooldown
22
22
  * means no second copy arrives for 24 hours.
23
23
  *
24
- * **Discount codes.** Subscribing mints nothing. For a "10% off your first
25
- * order" offer, the merchant creates a coupon in the dashboard with the
26
- * `customer_first_order` condition and you render that fixed code in the
27
- * success state — see the commented line below. Show it immediately: it must
28
- * not wait for the confirmation click, or the shopper loses the reason they
29
- * filled the form in.
24
+ * **The welcome offer.** `marketing.getBenefit()` returns what the merchant is
25
+ * offering people who confirm, or `null` when they offer nothing. Show its
26
+ * terms beside the field so the shopper knows what they are signing up for.
27
+ *
28
+ * DO NOT RENDER A COUPON CODE HERE, now or after the success state. No coupon
29
+ * exists at either moment. It is minted when the recipient clicks the link in
30
+ * their own mailbox and is emailed to them there — which is exactly what stops a
31
+ * forwarded link handing the discount to someone who never asked. The
32
+ * confirmation page is served by the API and already shows the code, its expiry
33
+ * and its terms, so there is nothing here to poll for either.
34
+ *
35
+ * ⛔ There is no eligibility check to call, deliberately: a per-address answer
36
+ * would let anyone test who already subscribed. The offer is one per address per
37
+ * store, forever. Say so in the terms rather than trying to detect it.
30
38
  *
31
39
  * Rate limited to 3 requests / 60s per IP.
32
40
  */
33
41
 
34
- import { useMemo, useState } from 'react';
42
+ import { useEffect, useMemo, useState } from 'react';
43
+ import type { PublicNewsletterBenefitOffer } from 'brainerce';
35
44
  import { getClient } from '@/core/lib/brainerce';
36
45
  import { useTranslations } from '@/core/lib/translations';
37
46
 
@@ -42,6 +51,10 @@ export function NewsletterSignup() {
42
51
  const [loading, setLoading] = useState(false);
43
52
  const [done, setDone] = useState(false);
44
53
  const [error, setError] = useState<string | null>(null);
54
+ // null covers both "no offer configured" and "the read failed". Neither is
55
+ // worth an error state on a footer signup: the form still works, it just
56
+ // promises nothing, and promising nothing is always safe.
57
+ const [offer, setOffer] = useState<PublicNewsletterBenefitOffer | null>(null);
45
58
 
46
59
  // Same source the contact form uses — <html lang> is set from the active
47
60
  // locale, and passing it is what decides the language of the confirmation
@@ -51,6 +64,31 @@ export function NewsletterSignup() {
51
64
  return undefined;
52
65
  }, []);
53
66
 
67
+ // Read once per mount. The offer belongs to the store, not to the visitor,
68
+ // so re-reading it per keystroke or per submit is wasted work.
69
+ useEffect(() => {
70
+ let cancelled = false;
71
+ getClient()
72
+ .marketing.getBenefit(locale)
73
+ .then((result) => {
74
+ if (!cancelled) setOffer(result);
75
+ })
76
+ .catch(() => {
77
+ // Silent on purpose. A failed offer read must not stop someone
78
+ // subscribing, and there is nothing the shopper could do about it.
79
+ });
80
+ return () => {
81
+ cancelled = true;
82
+ };
83
+ }, [locale]);
84
+
85
+ const offerHeadline = offer
86
+ ? offer.headline?.trim() ||
87
+ (offer.discountType === 'PERCENTAGE'
88
+ ? t('offerPercent', { value: String(offer.discountValue) })
89
+ : t('offerAmount', { value: String(offer.discountValue) }))
90
+ : null;
91
+
54
92
  async function handleSubmit(e: React.FormEvent) {
55
93
  e.preventDefault();
56
94
  if (loading || !email.trim()) return;
@@ -80,11 +118,10 @@ export function NewsletterSignup() {
80
118
  <div className="space-y-1">
81
119
  <p className="text-sm font-medium">{t('checkEmailTitle')}</p>
82
120
  <p className="text-sm opacity-70">{t('checkEmailBody')}</p>
83
- {/*
84
- Merchant offering a signup discount? Render the coupon code here, now
85
- not after the confirmation click, which may never come:
86
- <p className="text-sm font-medium">{t('discountCode', { code: 'WELCOME10' })}</p>
87
- */}
121
+ {/* Says where the code comes from, without ever showing one. The coupon
122
+ is created by the confirmation click and arrives in the same inbox,
123
+ so "check your email" is the whole instruction. */}
124
+ {offer ? <p className="text-sm opacity-70">{t('offerByEmail')}</p> : null}
88
125
  </div>
89
126
  );
90
127
  }
@@ -96,6 +133,16 @@ export function NewsletterSignup() {
96
133
  </label>
97
134
  <p className="text-sm opacity-70">{t('subtitle')}</p>
98
135
 
136
+ {/* The merchant's own words when they wrote any, a plain statement of the
137
+ discount when they did not. Both are the promise the shopper is
138
+ agreeing to, so they belong above the input, not under the button. */}
139
+ {offerHeadline ? (
140
+ <div className="space-y-0.5">
141
+ <p className="text-sm font-medium">{offerHeadline}</p>
142
+ {offer?.terms ? <p className="text-xs opacity-70">{offer.terms}</p> : null}
143
+ </div>
144
+ ) : null}
145
+
99
146
  <div className="flex flex-wrap gap-2">
100
147
  <input
101
148
  id="newsletter-email"
@@ -17,9 +17,18 @@
17
17
  *
18
18
  * ⛔ KEEP THE HONEYPOT. It is the bot filter; deleting the input silently opens
19
19
  * the form up.
20
+ *
21
+ * **The welcome offer.** `marketing.getBenefit()` returns what the merchant
22
+ * offers people who confirm, or `null` when they offer nothing. Show its terms
23
+ * beside the field. ⛔ NEVER RENDER A COUPON CODE — none exists here or in the
24
+ * success state; it is minted by the confirmation click and emailed to the
25
+ * recipient, which is what stops a forwarded link handing the discount to
26
+ * someone who never asked. There is no eligibility check to call either: a
27
+ * per-address answer would let anyone test who already subscribed.
20
28
  */
21
29
 
22
- import { useMemo, useState } from 'react';
30
+ import { useEffect, useMemo, useState } from 'react';
31
+ import type { PublicNewsletterBenefitOffer } from 'brainerce';
23
32
  import { getClient } from '@/core/lib/brainerce';
24
33
  import { useTranslations } from '@/core/lib/translations';
25
34
 
@@ -30,6 +39,9 @@ export function NewsletterSignup() {
30
39
  const [loading, setLoading] = useState(false);
31
40
  const [done, setDone] = useState(false);
32
41
  const [error, setError] = useState<string | null>(null);
42
+ // null covers "no offer" and "the read failed" alike. Neither deserves an
43
+ // error state: the form still works and simply promises nothing.
44
+ const [offer, setOffer] = useState<PublicNewsletterBenefitOffer | null>(null);
33
45
 
34
46
  // <html lang> carries the active locale, and passing it decides the language
35
47
  // of the confirmation email. Omit it and a Hebrew shopper gets English.
@@ -38,6 +50,29 @@ export function NewsletterSignup() {
38
50
  return undefined;
39
51
  }, []);
40
52
 
53
+ // Once per mount. The offer belongs to the store, not the visitor.
54
+ useEffect(() => {
55
+ let cancelled = false;
56
+ getClient()
57
+ .marketing.getBenefit(locale)
58
+ .then((result) => {
59
+ if (!cancelled) setOffer(result);
60
+ })
61
+ .catch(() => {
62
+ // Silent: a failed offer read must not stop anyone subscribing.
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [locale]);
68
+
69
+ const offerHeadline = offer
70
+ ? offer.headline?.trim() ||
71
+ (offer.discountType === 'PERCENTAGE'
72
+ ? t('offerPercent', { value: String(offer.discountValue) })
73
+ : t('offerAmount', { value: String(offer.discountValue) }))
74
+ : null;
75
+
41
76
  async function handleSubmit(e: React.FormEvent) {
42
77
  e.preventDefault();
43
78
  if (loading || !email.trim()) return;
@@ -68,13 +103,9 @@ export function NewsletterSignup() {
68
103
  {/* DESIGN ME — newsletter success state. Do NOT reword to "you're subscribed": the address is not on the list until the recipient clicks the emailed link. Naming the spam folder is deliberate — a filtered confirmation is the commonest reason a signup never converts, and no second copy is sent for 24 hours. */}
69
104
  <p>{t('checkEmailTitle')}</p>
70
105
  <p>{t('checkEmailBody')}</p>
71
- {/*
72
- Merchant offering a signup discount? Render the coupon code here, now
73
- not after the confirmation click, which may never come:
74
- <p>{t('discountCode', { code: 'WELCOME10' })}</p>
75
- The code comes from a dashboard coupon with the `customer_first_order`
76
- condition; subscribing mints nothing on its own.
77
- */}
106
+ {/* Says where the code comes from without ever showing one: the coupon
107
+ is created by the confirmation click and lands in the same inbox. */}
108
+ {offer ? <p>{t('offerByEmail')}</p> : null}
78
109
  </div>
79
110
  );
80
111
  }
@@ -85,6 +116,17 @@ export function NewsletterSignup() {
85
116
  <label htmlFor="newsletter-email">{t('title')}</label>
86
117
  <p>{t('subtitle')}</p>
87
118
 
119
+ {/* DESIGN ME — the welcome offer, when the merchant configured one. Keep
120
+ both lines: the headline is the promise and the terms are its
121
+ conditions, and a shopper who reads one without the other is being
122
+ mis-sold. Never add a coupon code here. */}
123
+ {offerHeadline ? (
124
+ <div>
125
+ <p>{offerHeadline}</p>
126
+ {offer?.terms ? <p>{offer.terms}</p> : null}
127
+ </div>
128
+ ) : null}
129
+
88
130
  <input
89
131
  id="newsletter-email"
90
132
  type="email"