create-brainerce-store 1.59.0 → 1.62.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 +12 -2
- package/messages/en.json +17 -0
- package/messages/he.json +17 -0
- package/package.json +2 -1
- package/templates/nextjs/base/.mcp.json +8 -0
- package/templates/nextjs/base/AGENTS.md.ejs +81 -65
- package/templates/nextjs/base/CLAUDE.md.ejs +92 -76
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +12 -4
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +16 -1
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +50 -2
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +24 -2
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +20 -16
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -0
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +17 -1
- package/templates/nextjs/designs/atelier/messages-patch/en.json +7 -7
- package/templates/nextjs/designs/atelier/messages-patch/he.json +8 -8
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +16 -14
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
|
+
version: "1.62.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
|
@@ -43,6 +43,7 @@ var require_package = __commonJS({
|
|
|
43
43
|
],
|
|
44
44
|
scripts: {
|
|
45
45
|
build: "tsup",
|
|
46
|
+
"type-check": "tsc --noEmit",
|
|
46
47
|
dev: "tsup --watch",
|
|
47
48
|
clean: "rimraf dist",
|
|
48
49
|
release: "pnpm build && node ../../scripts/publish-workspace-package.js .",
|
|
@@ -182,7 +183,16 @@ var BRAINERCE_RUNTIME_DEPS = Object.freeze({
|
|
|
182
183
|
// SetCheckoutCustomerDto — the scaffolded checkout form types depend on it.
|
|
183
184
|
// 1.46 adds getAddressSuggestions/getAddressDetails — the scaffolded
|
|
184
185
|
// checkout form's address-autocomplete typeahead calls these directly.
|
|
185
|
-
|
|
186
|
+
// 1.52 adds ShippingRate.speedTier — the scaffolded shipping step reads it to
|
|
187
|
+
// label live carrier rates, so an older SDK fails type-check at scaffold time
|
|
188
|
+
// — plus getBusinessHoursForDate(), which the scaffolded DATETIME checkout
|
|
189
|
+
// field uses to tell "day closed" from "field isn't slot-based".
|
|
190
|
+
// 1.53 strips lat/lng/formattedAddress from setShippingAddress/
|
|
191
|
+
// setBillingAddress bodies. The address endpoints reject ANY unknown
|
|
192
|
+
// property with a 400 that blocks checkout outright, and those three come
|
|
193
|
+
// straight off getAddressDetails() — so a scaffolded store on an older SDK
|
|
194
|
+
// is one `...address` spread away from a checkout nobody can complete.
|
|
195
|
+
brainerce: "^1.53.0",
|
|
186
196
|
"isomorphic-dompurify": "^3.8.0"
|
|
187
197
|
});
|
|
188
198
|
|
package/messages/en.json
CHANGED
|
@@ -189,6 +189,9 @@
|
|
|
189
189
|
"noShippingOptions": "No shipping options available for this address.",
|
|
190
190
|
"noShippingOptionsHint": "Please try a different address or contact support.",
|
|
191
191
|
"estimatedDelivery": "Estimated delivery:",
|
|
192
|
+
"shippingTierCheapest": "Standard delivery",
|
|
193
|
+
"shippingTierBalanced": "Express delivery",
|
|
194
|
+
"shippingTierFastest": "Priority delivery",
|
|
192
195
|
"pickupLocation": "Pickup Location",
|
|
193
196
|
"selectPickupLocation": "Select pickup location",
|
|
194
197
|
"pickupLocationRequired": "Please select a pickup location",
|
|
@@ -250,6 +253,8 @@
|
|
|
250
253
|
"customFieldsDateClear": "Clear",
|
|
251
254
|
"customFieldsDatePrevMonth": "Previous month",
|
|
252
255
|
"customFieldsDateNextMonth": "Next month",
|
|
256
|
+
"customFieldsTimeLabel": "Time",
|
|
257
|
+
"customFieldsTimeClosed": "No delivery times are available on this date.",
|
|
253
258
|
"changeOptions": "Change options",
|
|
254
259
|
"surcharges": "Additional charges"
|
|
255
260
|
},
|
|
@@ -340,6 +345,10 @@
|
|
|
340
345
|
"goToRegister": "Go to Register",
|
|
341
346
|
"authFailed": "Authentication failed",
|
|
342
347
|
"authFailedDesc": "Missing authentication parameters. Please try again.",
|
|
348
|
+
"oauthErrorGeneric": "Sign-in could not be completed. Please try again.",
|
|
349
|
+
"oauthErrorCancelled": "Sign-in was cancelled.",
|
|
350
|
+
"oauthErrorExpired": "That sign-in attempt expired. Please try again.",
|
|
351
|
+
"oauthErrorVerifyEmailFirst": "This email address needs to be verified before you can sign in with a social account. Check your inbox for the verification email.",
|
|
343
352
|
"backToLogin": "Back to Login",
|
|
344
353
|
"completingSignIn": "Completing sign in...",
|
|
345
354
|
"forgotPassword": "Forgot password?",
|
|
@@ -450,6 +459,14 @@
|
|
|
450
459
|
"yourDownloads": "Your Downloads",
|
|
451
460
|
"download": "Download"
|
|
452
461
|
},
|
|
462
|
+
"orderStatus": {
|
|
463
|
+
"pageTitle": "Order Status",
|
|
464
|
+
"loading": "Loading your order...",
|
|
465
|
+
"errorTitle": "Order not found",
|
|
466
|
+
"notFound": "We couldn't find an order matching this link.",
|
|
467
|
+
"missingCheckoutInfo": "Missing order information.",
|
|
468
|
+
"returnHome": "Return Home"
|
|
469
|
+
},
|
|
453
470
|
"reservation": {
|
|
454
471
|
"expired": "Reservation expired. Items may no longer be available.",
|
|
455
472
|
"hurry": "Hurry!",
|
package/messages/he.json
CHANGED
|
@@ -189,6 +189,9 @@
|
|
|
189
189
|
"noShippingOptions": "אין אפשרויות משלוח זמינות לכתובת זו.",
|
|
190
190
|
"noShippingOptionsHint": "נסו כתובת אחרת או פנו לתמיכה.",
|
|
191
191
|
"estimatedDelivery": "זמן משלוח משוער:",
|
|
192
|
+
"shippingTierCheapest": "משלוח רגיל",
|
|
193
|
+
"shippingTierBalanced": "משלוח מהיר",
|
|
194
|
+
"shippingTierFastest": "משלוח אקספרס",
|
|
192
195
|
"pickupLocation": "נקודת איסוף",
|
|
193
196
|
"selectPickupLocation": "בחרו נקודת איסוף",
|
|
194
197
|
"pickupLocationRequired": "יש לבחור נקודת איסוף",
|
|
@@ -250,6 +253,8 @@
|
|
|
250
253
|
"customFieldsDateClear": "נקה",
|
|
251
254
|
"customFieldsDatePrevMonth": "חודש קודם",
|
|
252
255
|
"customFieldsDateNextMonth": "חודש הבא",
|
|
256
|
+
"customFieldsTimeLabel": "שעה",
|
|
257
|
+
"customFieldsTimeClosed": "אין שעות משלוח זמינות בתאריך הזה.",
|
|
253
258
|
"changeOptions": "שנה אפשרויות",
|
|
254
259
|
"surcharges": "תוספות"
|
|
255
260
|
},
|
|
@@ -340,6 +345,10 @@
|
|
|
340
345
|
"goToRegister": "מעבר להרשמה",
|
|
341
346
|
"authFailed": "ההתחברות נכשלה",
|
|
342
347
|
"authFailedDesc": "חסרים פרמטרי התחברות. אנא נסו שוב.",
|
|
348
|
+
"oauthErrorGeneric": "לא הצלחנו להשלים את ההתחברות. אנא נסו שוב.",
|
|
349
|
+
"oauthErrorCancelled": "ההתחברות בוטלה.",
|
|
350
|
+
"oauthErrorExpired": "ניסיון ההתחברות פג. אנא נסו שוב.",
|
|
351
|
+
"oauthErrorVerifyEmailFirst": "יש לאמת את כתובת המייל הזו לפני התחברות עם חשבון חברתי. בדקו את תיבת הדואר שלכם עבור מייל האימות.",
|
|
343
352
|
"backToLogin": "חזרה להתחברות",
|
|
344
353
|
"completingSignIn": "משלים התחברות...",
|
|
345
354
|
"forgotPassword": "שכחת סיסמא?",
|
|
@@ -450,6 +459,14 @@
|
|
|
450
459
|
"yourDownloads": "ההורדות שלך",
|
|
451
460
|
"download": "הורדה"
|
|
452
461
|
},
|
|
462
|
+
"orderStatus": {
|
|
463
|
+
"pageTitle": "סטטוס הזמנה",
|
|
464
|
+
"loading": "טוען את ההזמנה שלך...",
|
|
465
|
+
"errorTitle": "ההזמנה לא נמצאה",
|
|
466
|
+
"notFound": "לא הצלחנו למצוא הזמנה שתואמת לקישור הזה.",
|
|
467
|
+
"missingCheckoutInfo": "חסר מידע הזמנה.",
|
|
468
|
+
"returnHome": "חזרה לדף הבית"
|
|
469
|
+
},
|
|
453
470
|
"reservation": {
|
|
454
471
|
"expired": "השריון פג. ייתכן שהמוצרים כבר לא זמינים.",
|
|
455
472
|
"hurry": "מהרו!",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-brainerce-store",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.62.0",
|
|
4
4
|
"description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-brainerce-store": "dist/index.js"
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsup",
|
|
15
|
+
"type-check": "tsc --noEmit",
|
|
15
16
|
"dev": "tsup --watch",
|
|
16
17
|
"clean": "rimraf dist",
|
|
17
18
|
"release": "pnpm build && node ../../scripts/publish-workspace-package.js .",
|
|
@@ -1,65 +1,81 @@
|
|
|
1
|
-
# AGENTS.md — this is a LIVE Brainerce storefront
|
|
2
|
-
|
|
3
|
-
**Store: "<%- storeName %>" · sales channel `<%- connectionId %>` — already
|
|
4
|
-
connected.** Products, cart, checkout, coupons, discounts, orders and content
|
|
5
|
-
flow in real time from the Brainerce dashboard. There is nothing to hook up.
|
|
6
|
-
|
|
7
|
-
- **NEVER suggest connecting this store to Shopify, WooCommerce, or "a real
|
|
8
|
-
system"** — Brainerce IS the commerce backend, and this store is wired to
|
|
9
|
-
it end-to-end.
|
|
10
|
-
- **NEVER build standalone HTML mockups or demo pages** — design THIS Next.js
|
|
11
|
-
app. Run `pnpm dev` and you are working against live data.
|
|
12
|
-
- **NEVER hardcode products, prices, or currency** — the catalog is live.
|
|
13
|
-
- **No server side is needed** — the backend is Brainerce's cloud. This repo
|
|
14
|
-
is a frontend (plus thin, already-included API proxy routes under
|
|
15
|
-
`src/app/api/`). Do not scaffold databases, auth servers, or admin panels —
|
|
16
|
-
the merchant manages everything in the Brainerce dashboard.
|
|
17
|
-
|
|
18
|
-
Platform docs (endpoints, SDK, integration recipes): https://brainerce.com/docs
|
|
19
|
-
— AI-readable index: https://brainerce.com/llms.txt
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
##
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
1
|
+
# AGENTS.md — this is a LIVE Brainerce storefront
|
|
2
|
+
|
|
3
|
+
**Store: "<%- storeName %>" · sales channel `<%- connectionId %>` — already
|
|
4
|
+
connected.** Products, cart, checkout, coupons, discounts, orders and content
|
|
5
|
+
flow in real time from the Brainerce dashboard. There is nothing to hook up.
|
|
6
|
+
|
|
7
|
+
- **NEVER suggest connecting this store to Shopify, WooCommerce, or "a real
|
|
8
|
+
system"** — Brainerce IS the commerce backend, and this store is wired to
|
|
9
|
+
it end-to-end.
|
|
10
|
+
- **NEVER build standalone HTML mockups or demo pages** — design THIS Next.js
|
|
11
|
+
app. Run `pnpm dev` and you are working against live data.
|
|
12
|
+
- **NEVER hardcode products, prices, or currency** — the catalog is live.
|
|
13
|
+
- **No server side is needed** — the backend is Brainerce's cloud. This repo
|
|
14
|
+
is a frontend (plus thin, already-included API proxy routes under
|
|
15
|
+
`src/app/api/`). Do not scaffold databases, auth servers, or admin panels —
|
|
16
|
+
the merchant manages everything in the Brainerce dashboard.
|
|
17
|
+
|
|
18
|
+
Platform docs (endpoints, SDK, integration recipes): https://brainerce.com/docs
|
|
19
|
+
— AI-readable index: https://brainerce.com/llms.txt
|
|
20
|
+
|
|
21
|
+
## MCP servers
|
|
22
|
+
|
|
23
|
+
- **`brainerce-docs`** — already connected via `.mcp.json`, no auth needed.
|
|
24
|
+
A live SDK/docs oracle: current SDK method shapes, type definitions, code
|
|
25
|
+
examples, and this store's actual capabilities/config. Prefer it over
|
|
26
|
+
guessing or relying on training data — it reflects what's actually toggled
|
|
27
|
+
on for `<%- connectionId %>` right now.
|
|
28
|
+
- **Brainerce Admin MCP** — opt-in, not wired up by default. Lets an agent
|
|
29
|
+
directly manage this store's *live* data (products, orders, discounts,
|
|
30
|
+
shipping, …) instead of just reading docs. Only add it if the merchant
|
|
31
|
+
wants that: connect `https://api.brainerce.com/api/mcp` as a remote HTTP
|
|
32
|
+
MCP server (e.g. `claude mcp add --transport http brainerce-admin
|
|
33
|
+
https://api.brainerce.com/api/mcp`) — it opens a browser to log into the
|
|
34
|
+
Brainerce dashboard, pick this store, and grant scoped OAuth permissions.
|
|
35
|
+
Treat it like handing the agent write access to production commerce data.
|
|
36
|
+
|
|
37
|
+
**Before building any feature the merchant asks for** (loyalty points,
|
|
38
|
+
shipping zones, subscriptions, gift cards, multi-currency, reviews,
|
|
39
|
+
abandoned-cart recovery, etc.) — check the docs first. Brainerce likely
|
|
40
|
+
already has it as a platform capability (dashboard toggle + hook/SDK
|
|
41
|
+
method) that only needs a UI in `src/ui/`, not a feature built from scratch.
|
|
42
|
+
|
|
43
|
+
Your job here is almost always **design**.
|
|
44
|
+
|
|
45
|
+
## The one rule
|
|
46
|
+
|
|
47
|
+
**`src/core/` is the platform's. `src/ui/` is yours.**
|
|
48
|
+
|
|
49
|
+
The shipped `src/ui/` is a working reference, not a design to preserve —
|
|
50
|
+
full delete-and-rebuild of ui files is encouraged and expected.
|
|
51
|
+
Rewrite anything under `src/ui/` and `src/app/globals.css` as boldly as you
|
|
52
|
+
like — the store keeps working. Never modify `src/core/`, `src/app/api/`, or
|
|
53
|
+
the checkout/auth/account components. Data and behavior come exclusively from
|
|
54
|
+
`@/core/hooks/*` and `@/core/providers/store-provider` — hooks return state
|
|
55
|
+
and handlers, never JSX. Never hardcode catalog content.
|
|
56
|
+
|
|
57
|
+
**Read `AI-GUIDE.md` before any redesign** — it has the full file map, hook
|
|
58
|
+
contracts, motion language, and hard-won RTL/i18n gotchas that will save you
|
|
59
|
+
real debugging time.
|
|
60
|
+
|
|
61
|
+
## Redesigning?
|
|
62
|
+
|
|
63
|
+
Follow the process documented in AI-GUIDE.md: commit to one art direction,
|
|
64
|
+
rewrite tokens first, then surfaces in order (header, home, card, product
|
|
65
|
+
page, cart), then verify.
|
|
66
|
+
|
|
67
|
+
## Verify before declaring done
|
|
68
|
+
|
|
69
|
+
1. `pnpm exec tsc --noEmit` → 0 errors
|
|
70
|
+
2. `pnpm dev` → drive the changed flow in a real browser (home → product →
|
|
71
|
+
add to cart → cart)
|
|
72
|
+
3. Screenshot desktop (1440px) and mobile (390px)
|
|
73
|
+
4. RTL stores: check anchoring and arrow directions
|
|
74
|
+
|
|
75
|
+
## i18n
|
|
76
|
+
|
|
77
|
+
Every user-facing string goes through `useTranslations()` with keys in **all**
|
|
78
|
+
files under `messages/`. The shipped copy is example boutique content — a
|
|
79
|
+
starting point meant to be rewritten in the store's real voice. Hebrew: no
|
|
80
|
+
uppercase transforms, no wide letter-spacing on headings, logical CSS
|
|
81
|
+
properties only (`ms-/me-`, `ps-/pe-`, `start-/end-`).
|
|
@@ -1,76 +1,92 @@
|
|
|
1
|
-
# CLAUDE.md — this is a LIVE Brainerce storefront
|
|
2
|
-
|
|
3
|
-
**Store: "<%- storeName %>" · sales channel `<%- connectionId %>` — already
|
|
4
|
-
connected.** Products, cart, checkout, coupons, discounts, orders and content
|
|
5
|
-
flow in real time from the Brainerce dashboard. There is nothing to hook up.
|
|
6
|
-
|
|
7
|
-
- **NEVER suggest connecting this store to Shopify, WooCommerce, or "a real
|
|
8
|
-
system"** — Brainerce IS the commerce backend, and this store is wired to
|
|
9
|
-
it end-to-end.
|
|
10
|
-
- **NEVER build standalone HTML mockups or demo pages** — design THIS Next.js
|
|
11
|
-
app. Run `pnpm dev` and you are working against live data.
|
|
12
|
-
- **NEVER hardcode products, prices, or currency** — the catalog is live.
|
|
13
|
-
- **No server side is needed** — the backend is Brainerce's cloud. This repo
|
|
14
|
-
is a frontend (plus thin, already-included API proxy routes under
|
|
15
|
-
`src/app/api/`). Do not scaffold databases, auth servers, or admin panels —
|
|
16
|
-
the merchant manages everything in the Brainerce dashboard.
|
|
17
|
-
|
|
18
|
-
Platform docs (endpoints, SDK, integration recipes): https://brainerce.com/docs
|
|
19
|
-
— AI-readable index: https://brainerce.com/llms.txt
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
1
|
+
# CLAUDE.md — this is a LIVE Brainerce storefront
|
|
2
|
+
|
|
3
|
+
**Store: "<%- storeName %>" · sales channel `<%- connectionId %>` — already
|
|
4
|
+
connected.** Products, cart, checkout, coupons, discounts, orders and content
|
|
5
|
+
flow in real time from the Brainerce dashboard. There is nothing to hook up.
|
|
6
|
+
|
|
7
|
+
- **NEVER suggest connecting this store to Shopify, WooCommerce, or "a real
|
|
8
|
+
system"** — Brainerce IS the commerce backend, and this store is wired to
|
|
9
|
+
it end-to-end.
|
|
10
|
+
- **NEVER build standalone HTML mockups or demo pages** — design THIS Next.js
|
|
11
|
+
app. Run `pnpm dev` and you are working against live data.
|
|
12
|
+
- **NEVER hardcode products, prices, or currency** — the catalog is live.
|
|
13
|
+
- **No server side is needed** — the backend is Brainerce's cloud. This repo
|
|
14
|
+
is a frontend (plus thin, already-included API proxy routes under
|
|
15
|
+
`src/app/api/`). Do not scaffold databases, auth servers, or admin panels —
|
|
16
|
+
the merchant manages everything in the Brainerce dashboard.
|
|
17
|
+
|
|
18
|
+
Platform docs (endpoints, SDK, integration recipes): https://brainerce.com/docs
|
|
19
|
+
— AI-readable index: https://brainerce.com/llms.txt
|
|
20
|
+
|
|
21
|
+
## MCP servers
|
|
22
|
+
|
|
23
|
+
- **`brainerce-docs`** — already connected via `.mcp.json`, no auth needed.
|
|
24
|
+
A live SDK/docs oracle: current SDK method shapes, type definitions, code
|
|
25
|
+
examples, and this store's actual capabilities/config. Prefer it over
|
|
26
|
+
guessing or relying on training data — it reflects what's actually toggled
|
|
27
|
+
on for `<%- connectionId %>` right now.
|
|
28
|
+
- **Brainerce Admin MCP** — opt-in, not wired up by default. Lets an agent
|
|
29
|
+
directly manage this store's *live* data (products, orders, discounts,
|
|
30
|
+
shipping, …) instead of just reading docs. Only add it if the merchant
|
|
31
|
+
wants that: connect `https://api.brainerce.com/api/mcp` as a remote HTTP
|
|
32
|
+
MCP server (e.g. `claude mcp add --transport http brainerce-admin
|
|
33
|
+
https://api.brainerce.com/api/mcp`) — it opens a browser to log into the
|
|
34
|
+
Brainerce dashboard, pick this store, and grant scoped OAuth permissions.
|
|
35
|
+
Treat it like handing the agent write access to production commerce data.
|
|
36
|
+
|
|
37
|
+
**Before building any feature the merchant asks for** (loyalty points,
|
|
38
|
+
shipping zones, subscriptions, gift cards, multi-currency, reviews,
|
|
39
|
+
abandoned-cart recovery, etc.) — check the docs first. Brainerce likely
|
|
40
|
+
already has it as a platform capability (dashboard toggle + hook/SDK
|
|
41
|
+
method) that only needs a UI in `src/ui/`, not a feature built from scratch.
|
|
42
|
+
|
|
43
|
+
Your job here is almost always **design**.
|
|
44
|
+
|
|
45
|
+
## The one rule
|
|
46
|
+
|
|
47
|
+
**`src/core/` is the platform's. `src/ui/` is yours.**
|
|
48
|
+
|
|
49
|
+
The shipped `src/ui/` is a working reference, not a design to preserve —
|
|
50
|
+
full delete-and-rebuild of ui files is encouraged and expected. (Stores
|
|
51
|
+
scaffolded with `--canvas` ship `src/ui/` as bare unstyled skeletons —
|
|
52
|
+
there is no reference look at all; the design is entirely yours to create.)
|
|
53
|
+
Rewrite anything under `src/ui/` and `src/app/globals.css` as boldly as you
|
|
54
|
+
like — the store keeps working. Never modify `src/core/`, `src/app/api/`, or
|
|
55
|
+
the checkout/auth/account components. Data and behavior come exclusively from
|
|
56
|
+
`@/core/hooks/*` and `@/core/providers/store-provider` — hooks return state
|
|
57
|
+
and handlers, never JSX. Never hardcode catalog content.
|
|
58
|
+
|
|
59
|
+
**Read `AI-GUIDE.md` before any redesign** — it has the full file map, hook
|
|
60
|
+
contracts, motion language, and hard-won RTL/i18n gotchas that will save you
|
|
61
|
+
real debugging time.
|
|
62
|
+
|
|
63
|
+
## The dialect
|
|
64
|
+
|
|
65
|
+
- `src/components/ui/` = official **shadcn/ui primitives** (Button, Card,
|
|
66
|
+
Badge, Input, Select, Accordion, Dialog, Sheet, Skeleton, …). Compose them;
|
|
67
|
+
extend looks via CVA **variants**, never repeated inline overrides.
|
|
68
|
+
- Icons: **lucide-react only** — no inline `<svg>`, no emoji-as-icons.
|
|
69
|
+
- Class merging: **`cn()`** from `@/core/lib/utils`.
|
|
70
|
+
- Colors/radius come from the semantic HSL tokens in `globals.css`
|
|
71
|
+
(`--primary`, `--card`, `--ring`, `--radius`, …) — restyle via tokens.
|
|
72
|
+
|
|
73
|
+
## Redesigning? Use the ready-made flow
|
|
74
|
+
|
|
75
|
+
Run `/design <your art-direction brief>` — it walks the whole process:
|
|
76
|
+
concept → implementation in `ui/` → verification.
|
|
77
|
+
|
|
78
|
+
## Verify before declaring done
|
|
79
|
+
|
|
80
|
+
1. `pnpm exec tsc --noEmit` → 0 errors
|
|
81
|
+
2. `pnpm dev` → drive the changed flow in a real browser (home → product →
|
|
82
|
+
add to cart → cart)
|
|
83
|
+
3. Screenshot desktop (1440px) and mobile (390px)
|
|
84
|
+
4. RTL stores: check anchoring and arrow directions
|
|
85
|
+
|
|
86
|
+
## i18n
|
|
87
|
+
|
|
88
|
+
Every user-facing string goes through `useTranslations()` with keys in **all**
|
|
89
|
+
files under `messages/`. The shipped copy is example boutique content — a
|
|
90
|
+
starting point meant to be rewritten in the store's real voice. Hebrew: no
|
|
91
|
+
uppercase transforms, no wide letter-spacing on headings, logical CSS
|
|
92
|
+
properties only (`ms-/me-`, `ps-/pe-`, `start-/end-`).
|
|
@@ -13,8 +13,16 @@ function isSecure(): boolean {
|
|
|
13
13
|
return process.env.NODE_ENV === 'production';
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Forward a failure to the client callback page as a stable snake_case code.
|
|
18
|
+
*
|
|
19
|
+
* The backend uses the RFC 6749 §4.1.2.1 shape (`oauth_error` = code,
|
|
20
|
+
* `error_description` = English developer detail), so codes are what the page
|
|
21
|
+
* switches on to pick localized copy. The description is deliberately NOT
|
|
22
|
+
* forwarded — it is not shopper-facing copy.
|
|
23
|
+
*/
|
|
24
|
+
function errorRedirect(base: URL, code: string): NextResponse {
|
|
25
|
+
base.searchParams.set('oauth_error', code);
|
|
18
26
|
const response = NextResponse.redirect(base);
|
|
19
27
|
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
20
28
|
return response;
|
|
@@ -41,7 +49,7 @@ export async function GET(request: NextRequest) {
|
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
if (oauthSuccess !== 'true' || !authCode) {
|
|
44
|
-
return errorRedirect(redirectUrl, '
|
|
52
|
+
return errorRedirect(redirectUrl, 'invalid_request');
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
// Exchange the one-time auth_code for the real JWT.
|
|
@@ -61,7 +69,7 @@ export async function GET(request: NextRequest) {
|
|
|
61
69
|
token = data.token;
|
|
62
70
|
} catch (err) {
|
|
63
71
|
console.error('[oauth-callback] auth_code exchange failed:', err);
|
|
64
|
-
return errorRedirect(redirectUrl, '
|
|
72
|
+
return errorRedirect(redirectUrl, 'server_error');
|
|
65
73
|
}
|
|
66
74
|
|
|
67
75
|
redirectUrl.searchParams.set('oauth_success', 'true');
|
|
@@ -7,6 +7,21 @@ import { useAuth } from '@/core/providers/store-provider';
|
|
|
7
7
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
8
8
|
import { useTranslations } from '@/core/lib/translations';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* `oauth_error` is a stable snake_case code, not a message — the backend sends
|
|
12
|
+
* the RFC 6749 §4.1.2.1 pair (`oauth_error` + an English `error_description`
|
|
13
|
+
* meant for developers). Map codes to translated copy here; anything unknown,
|
|
14
|
+
* including provider-specific codes, falls through to the generic message.
|
|
15
|
+
*/
|
|
16
|
+
const OAUTH_ERROR_MESSAGE_KEYS: Record<string, string> = {
|
|
17
|
+
access_denied: 'oauthErrorCancelled',
|
|
18
|
+
invalid_request: 'oauthErrorExpired',
|
|
19
|
+
invalid_state: 'oauthErrorExpired',
|
|
20
|
+
state_already_used: 'oauthErrorExpired',
|
|
21
|
+
state_expired: 'oauthErrorExpired',
|
|
22
|
+
link_blocked_unverified_password_account: 'oauthErrorVerifyEmailFirst',
|
|
23
|
+
};
|
|
24
|
+
|
|
10
25
|
function OAuthCallbackContent() {
|
|
11
26
|
const router = useRouter();
|
|
12
27
|
const searchParams = useSearchParams();
|
|
@@ -25,7 +40,7 @@ function OAuthCallbackContent() {
|
|
|
25
40
|
processedRef.current = true;
|
|
26
41
|
|
|
27
42
|
if (oauthError) {
|
|
28
|
-
setError(oauthError);
|
|
43
|
+
setError(t(OAUTH_ERROR_MESSAGE_KEYS[oauthError] ?? 'oauthErrorGeneric'));
|
|
29
44
|
return;
|
|
30
45
|
}
|
|
31
46
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
import { Link } from '@/core/lib/navigation';
|
|
6
|
+
import type { Order } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
8
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
+
import { OrderHistory } from '@/components/account/order-history';
|
|
10
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
11
|
+
|
|
12
|
+
// Read-only order lookup for links from transactional emails (order confirmation,
|
|
13
|
+
// shipped, refunded, etc). Unlike /order-confirmation, this page never calls
|
|
14
|
+
// handlePaymentSuccess() or touches cart state — it only fetches and displays,
|
|
15
|
+
// so it's safe to revisit at any time without side effects on an unrelated
|
|
16
|
+
// active cart. Works for guests too: the checkout id itself is the credential.
|
|
17
|
+
function OrderStatusContent() {
|
|
18
|
+
const searchParams = useSearchParams();
|
|
19
|
+
const checkoutId = searchParams.get('checkout_id');
|
|
20
|
+
|
|
21
|
+
const t = useTranslations('orderStatus');
|
|
22
|
+
const tc = useTranslations('common');
|
|
23
|
+
const [order, setOrder] = useState<Order | null>(null);
|
|
24
|
+
const [loading, setLoading] = useState(true);
|
|
25
|
+
const [error, setError] = useState<string | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!checkoutId) {
|
|
29
|
+
setError(t('missingCheckoutInfo'));
|
|
30
|
+
setLoading(false);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
async function loadOrder() {
|
|
36
|
+
try {
|
|
37
|
+
const client = getClient();
|
|
38
|
+
const result = await client.getOrderByCheckout(checkoutId!);
|
|
39
|
+
if (!cancelled) setOrder(result);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (!cancelled) {
|
|
42
|
+
const message = err instanceof Error ? err.message : t('notFound');
|
|
43
|
+
setError(message);
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
if (!cancelled) setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
loadOrder();
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true;
|
|
53
|
+
};
|
|
54
|
+
}, [checkoutId, t]);
|
|
55
|
+
|
|
56
|
+
if (loading) {
|
|
57
|
+
return (
|
|
58
|
+
<div className="flex min-h-[60vh] flex-col items-center justify-center">
|
|
59
|
+
<LoadingSpinner size="lg" />
|
|
60
|
+
<p className="text-muted-foreground mt-4">{t('loading')}</p>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (error || !order) {
|
|
66
|
+
return (
|
|
67
|
+
<div className="mx-auto max-w-2xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
68
|
+
<svg
|
|
69
|
+
className="text-destructive mx-auto mb-4 h-16 w-16"
|
|
70
|
+
fill="none"
|
|
71
|
+
viewBox="0 0 24 24"
|
|
72
|
+
stroke="currentColor"
|
|
73
|
+
>
|
|
74
|
+
<path
|
|
75
|
+
strokeLinecap="round"
|
|
76
|
+
strokeLinejoin="round"
|
|
77
|
+
strokeWidth={1.5}
|
|
78
|
+
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.834-2.694-.834-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"
|
|
79
|
+
/>
|
|
80
|
+
</svg>
|
|
81
|
+
<h1 className="text-foreground text-2xl font-bold">{t('errorTitle')}</h1>
|
|
82
|
+
<p className="text-muted-foreground mt-2">{error || t('notFound')}</p>
|
|
83
|
+
<Link
|
|
84
|
+
href="/"
|
|
85
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
86
|
+
>
|
|
87
|
+
{t('returnHome')}
|
|
88
|
+
</Link>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
|
95
|
+
<h1 className="text-foreground mb-6 text-2xl font-bold">{t('pageTitle')}</h1>
|
|
96
|
+
<OrderHistory orders={[order]} />
|
|
97
|
+
<div className="mt-8 flex items-center justify-center">
|
|
98
|
+
<Link
|
|
99
|
+
href="/products"
|
|
100
|
+
className="bg-primary text-primary-foreground inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
101
|
+
>
|
|
102
|
+
{tc('continueShopping')}
|
|
103
|
+
</Link>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default function OrderStatusPage() {
|
|
110
|
+
return (
|
|
111
|
+
<Suspense
|
|
112
|
+
fallback={
|
|
113
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
114
|
+
<LoadingSpinner size="lg" />
|
|
115
|
+
</div>
|
|
116
|
+
}
|
|
117
|
+
>
|
|
118
|
+
<OrderStatusContent />
|
|
119
|
+
</Suspense>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -13,6 +13,18 @@ import { getClient } from '@/core/lib/brainerce';
|
|
|
13
13
|
const ADDRESS_SEARCH_DEBOUNCE_MS = 300;
|
|
14
14
|
const ADDRESS_SEARCH_MIN_CHARS = 3;
|
|
15
15
|
|
|
16
|
+
// Editing any of these invalidates a previously picked suggestion: its
|
|
17
|
+
// coordinates describe the address the shopper selected, not what is in the
|
|
18
|
+
// inputs now. See `pickedPlace` below.
|
|
19
|
+
const PLACE_INVALIDATING_FIELDS = new Set<string>([
|
|
20
|
+
'line1',
|
|
21
|
+
'line2',
|
|
22
|
+
'city',
|
|
23
|
+
'region',
|
|
24
|
+
'postalCode',
|
|
25
|
+
'country',
|
|
26
|
+
]);
|
|
27
|
+
|
|
16
28
|
interface CheckoutConsent {
|
|
17
29
|
acceptsMarketing: boolean;
|
|
18
30
|
saveDetails: boolean;
|
|
@@ -65,6 +77,16 @@ export function CheckoutForm({
|
|
|
65
77
|
const [showAddressSuggestions, setShowAddressSuggestions] = useState(false);
|
|
66
78
|
const [isSearchingAddress, setIsSearchingAddress] = useState(false);
|
|
67
79
|
const [outsideDeliveryZone, setOutsideDeliveryZone] = useState(false);
|
|
80
|
+
// The suggestion the shopper actually picked, kept so it can ride along to
|
|
81
|
+
// `setShippingAddress`. That is what lets the server match map-drawn
|
|
82
|
+
// ("polygon") delivery zones against this address's exact coordinates
|
|
83
|
+
// instead of geocoding the typed text — skipping it is the most common
|
|
84
|
+
// cause of "the store says it doesn't deliver here, but it does". Null
|
|
85
|
+
// whenever the address was typed by hand or edited after picking.
|
|
86
|
+
const [pickedPlace, setPickedPlace] = useState<{
|
|
87
|
+
placeId: string;
|
|
88
|
+
sessionToken: string;
|
|
89
|
+
} | null>(null);
|
|
68
90
|
const addressSessionToken = useRef(
|
|
69
91
|
typeof crypto !== 'undefined' ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
|
|
70
92
|
);
|
|
@@ -108,10 +130,14 @@ export function CheckoutForm({
|
|
|
108
130
|
|
|
109
131
|
async function handleSelectAddressSuggestion(suggestion: AddressSuggestion) {
|
|
110
132
|
setShowAddressSuggestions(false);
|
|
133
|
+
// The token this resolution is billed under — hand the SAME one to
|
|
134
|
+
// `setShippingAddress` so a server-side cache miss re-resolves within this
|
|
135
|
+
// autocomplete session instead of opening a new (billed) one.
|
|
136
|
+
const resolvingSessionToken = addressSessionToken.current;
|
|
111
137
|
try {
|
|
112
138
|
const { address, inZone } = await getClient().getAddressDetails(
|
|
113
139
|
suggestion.placeId,
|
|
114
|
-
|
|
140
|
+
resolvingSessionToken
|
|
115
141
|
);
|
|
116
142
|
setFormData((prev) => {
|
|
117
143
|
const country = address.country || prev.country;
|
|
@@ -138,9 +164,15 @@ export function CheckoutForm({
|
|
|
138
164
|
};
|
|
139
165
|
});
|
|
140
166
|
setOutsideDeliveryZone(!inZone);
|
|
167
|
+
// Note we deliberately do NOT copy address.lat/lng into the form: no
|
|
168
|
+
// address endpoint accepts coordinates (zone matching decides which rate
|
|
169
|
+
// is charged, so the server resolves them from the placeId itself).
|
|
170
|
+
// They're yours to use for a map pin and nothing else.
|
|
171
|
+
setPickedPlace({ placeId: suggestion.placeId, sessionToken: resolvingSessionToken });
|
|
141
172
|
} catch {
|
|
142
173
|
// Resolution failed — keep whatever the shopper had typed/selected as
|
|
143
174
|
// the visible text; they can still fill in the rest manually.
|
|
175
|
+
setPickedPlace(null);
|
|
144
176
|
} finally {
|
|
145
177
|
// New session for the next address-entry attempt.
|
|
146
178
|
addressSessionToken.current =
|
|
@@ -212,7 +244,17 @@ export function CheckoutForm({
|
|
|
212
244
|
function handleSubmit(e: React.FormEvent) {
|
|
213
245
|
e.preventDefault();
|
|
214
246
|
if (validate()) {
|
|
215
|
-
onSubmit(
|
|
247
|
+
onSubmit(
|
|
248
|
+
{
|
|
249
|
+
...formData,
|
|
250
|
+
// Both undefined when the shopper typed the address by hand or
|
|
251
|
+
// edited it after picking a suggestion — the server then geocodes
|
|
252
|
+
// the text, which is the right behaviour once the two disagree.
|
|
253
|
+
placeId: pickedPlace?.placeId,
|
|
254
|
+
placeSessionToken: pickedPlace?.sessionToken,
|
|
255
|
+
},
|
|
256
|
+
{ acceptsMarketing, saveDetails: showSaveDetails && saveDetails }
|
|
257
|
+
);
|
|
216
258
|
}
|
|
217
259
|
}
|
|
218
260
|
|
|
@@ -225,6 +267,12 @@ export function CheckoutForm({
|
|
|
225
267
|
}
|
|
226
268
|
return next;
|
|
227
269
|
});
|
|
270
|
+
// Once any address field is edited, the picked suggestion no longer
|
|
271
|
+
// describes what is in the form — keeping its placeId would match delivery
|
|
272
|
+
// zones against the address the shopper originally selected.
|
|
273
|
+
if (PLACE_INVALIDATING_FIELDS.has(field)) {
|
|
274
|
+
setPickedPlace(null);
|
|
275
|
+
}
|
|
228
276
|
if (errors[field]) {
|
|
229
277
|
setErrors((prev) => {
|
|
230
278
|
const next = { ...prev };
|
|
@@ -5,6 +5,7 @@ import type { CheckoutCustomFieldDefinition } from 'brainerce';
|
|
|
5
5
|
import { useTranslations } from '@/core/lib/translations';
|
|
6
6
|
import { cn } from '@/core/lib/utils';
|
|
7
7
|
import { DatePicker } from '@/components/checkout/date-picker';
|
|
8
|
+
import { DateTimePicker } from '@/components/checkout/datetime-picker';
|
|
8
9
|
|
|
9
10
|
/** Inline, dependency-free — this file lives in the base template and must
|
|
10
11
|
* not assume any design pack (e.g. Atelier's icon set) is present; canvas
|
|
@@ -173,8 +174,7 @@ export function CustomFieldsStep({
|
|
|
173
174
|
value={(value as string) ?? ''}
|
|
174
175
|
onChange={(v) => onChange(field.key, v)}
|
|
175
176
|
required={field.required}
|
|
176
|
-
|
|
177
|
-
maxDate={field.dateAvailability?.maxDate ?? undefined}
|
|
177
|
+
availability={field.dateAvailability}
|
|
178
178
|
placeholder={t('customFieldsDatePlaceholder')}
|
|
179
179
|
todayLabel={t('customFieldsDateToday')}
|
|
180
180
|
clearLabel={t('customFieldsDateClear')}
|
|
@@ -185,6 +185,28 @@ export function CustomFieldsStep({
|
|
|
185
185
|
</div>
|
|
186
186
|
);
|
|
187
187
|
|
|
188
|
+
case 'DATETIME':
|
|
189
|
+
return (
|
|
190
|
+
<div key={field.key}>
|
|
191
|
+
{labelEl}
|
|
192
|
+
<DateTimePicker
|
|
193
|
+
id={`cf-${field.key}`}
|
|
194
|
+
value={(value as string) ?? ''}
|
|
195
|
+
onChange={(v) => onChange(field.key, v)}
|
|
196
|
+
required={field.required}
|
|
197
|
+
availability={field.dateAvailability}
|
|
198
|
+
placeholder={t('customFieldsDatePlaceholder')}
|
|
199
|
+
todayLabel={t('customFieldsDateToday')}
|
|
200
|
+
clearLabel={t('customFieldsDateClear')}
|
|
201
|
+
prevMonthLabel={t('customFieldsDatePrevMonth')}
|
|
202
|
+
nextMonthLabel={t('customFieldsDateNextMonth')}
|
|
203
|
+
timeLabel={t('customFieldsTimeLabel')}
|
|
204
|
+
closedLabel={t('customFieldsTimeClosed')}
|
|
205
|
+
/>
|
|
206
|
+
{helpEl}
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
|
|
188
210
|
case 'IMAGE': {
|
|
189
211
|
const isUploading = uploadingKeys.has(field.key);
|
|
190
212
|
return (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { isCalendarDateAllowed, type DateAvailabilityConstraints } from 'brainerce';
|
|
4
5
|
import { cn } from '@/core/lib/utils';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -13,17 +14,26 @@ import { cn } from '@/core/lib/utils';
|
|
|
13
14
|
* canvas scaffolds wipe `src/ui` and ship no pack, but never touch
|
|
14
15
|
* `src/components/`, so this must work without either.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* Day availability is delegated wholesale to the SDK's `isCalendarDateAllowed`
|
|
18
|
+
* — min/max date, blocked weekdays AND blocked specific dates in one call, the
|
|
19
|
+
* exact predicate the server re-runs on submit. Reimplementing any part of it
|
|
20
|
+
* here is how a picker ends up offering a day the API then rejects with 400.
|
|
19
21
|
*/
|
|
20
22
|
interface DatePickerProps {
|
|
21
23
|
id?: string;
|
|
22
24
|
value: string; // 'YYYY-MM-DD' or ''
|
|
23
25
|
onChange: (value: string) => void;
|
|
24
26
|
required?: boolean;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
/** The field's `dateAvailability`, passed straight through from the API. */
|
|
28
|
+
availability?: DateAvailabilityConstraints | null;
|
|
29
|
+
/**
|
|
30
|
+
* Extra per-day gate ANDed with `isCalendarDateAllowed`. DATETIME fields use
|
|
31
|
+
* it to grey out days that pass the calendar rules but have no business-hours
|
|
32
|
+
* window — those are closed all day, and offering them is what produces a
|
|
33
|
+
* "no business hours are configured for this day" 400 after the shopper has
|
|
34
|
+
* already committed to a date.
|
|
35
|
+
*/
|
|
36
|
+
isDateSelectable?: (dateYYYYMMDD: string) => boolean;
|
|
27
37
|
placeholder: string;
|
|
28
38
|
todayLabel: string;
|
|
29
39
|
clearLabel: string;
|
|
@@ -51,8 +61,8 @@ export function DatePicker({
|
|
|
51
61
|
value,
|
|
52
62
|
onChange,
|
|
53
63
|
required,
|
|
54
|
-
|
|
55
|
-
|
|
64
|
+
availability,
|
|
65
|
+
isDateSelectable,
|
|
56
66
|
placeholder,
|
|
57
67
|
todayLabel,
|
|
58
68
|
clearLabel,
|
|
@@ -72,8 +82,6 @@ export function DatePicker({
|
|
|
72
82
|
}, []);
|
|
73
83
|
|
|
74
84
|
const selected = parseYMD(value);
|
|
75
|
-
const min = minDate ? parseYMD(minDate) : null;
|
|
76
|
-
const max = maxDate ? parseYMD(maxDate) : null;
|
|
77
85
|
const today = new Date();
|
|
78
86
|
const [view, setView] = useState(() => {
|
|
79
87
|
const initial = selected ?? { y: today.getFullYear(), m: today.getMonth() };
|
|
@@ -100,13 +108,9 @@ export function DatePicker({
|
|
|
100
108
|
}, [open]);
|
|
101
109
|
|
|
102
110
|
function isDisabled(y: number, m: number, d: number): boolean {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (max && (y > max.y || (y === max.y && (m > max.m || (m === max.m && d > max.d))))) {
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
return false;
|
|
111
|
+
const ymd = toYMD(y, m, d);
|
|
112
|
+
if (!isCalendarDateAllowed(ymd, availability)) return true;
|
|
113
|
+
return isDateSelectable ? !isDateSelectable(ymd) : false;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
function selectDay(y: number, m: number, d: number) {
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
computeAvailableSlots,
|
|
6
|
+
getBusinessHoursForDate,
|
|
7
|
+
type DateAvailabilityConstraints,
|
|
8
|
+
} from 'brainerce';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
|
+
import { DatePicker } from '@/components/checkout/date-picker';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Date + time control for checkout `DATETIME` custom fields.
|
|
14
|
+
*
|
|
15
|
+
* **The value it emits is `"YYYY-MM-DDTHH:mm"` with no UTC offset**, which the
|
|
16
|
+
* API resolves in the *store's* timezone — the only frame a delivery slot has.
|
|
17
|
+
* Do not append an offset from the shopper's browser (a buyer travelling
|
|
18
|
+
* abroad would book a different hour than the one they tapped), and never
|
|
19
|
+
* concatenate a human slot label: `"2026-08-13" + "T13:00-14:00"` parses as a
|
|
20
|
+
* -14:00 UTC offset and silently books the following day.
|
|
21
|
+
*
|
|
22
|
+
* Which times exist comes from the SDK, not from a list hardcoded here:
|
|
23
|
+
* - `computeAvailableSlots` → discrete slot starts, when the field defines
|
|
24
|
+
* `slotDurationMinutes`. The submitted time must equal one of them exactly.
|
|
25
|
+
* - `getBusinessHoursForDate` → the open/close window, when it doesn't. Slots
|
|
26
|
+
* being empty does NOT mean the day is closed; that distinction is why this
|
|
27
|
+
* component asks for the windows separately.
|
|
28
|
+
*
|
|
29
|
+
* Dependency-free from any design pack, same as `date-picker.tsx`.
|
|
30
|
+
*/
|
|
31
|
+
interface DateTimePickerProps {
|
|
32
|
+
id?: string;
|
|
33
|
+
/** 'YYYY-MM-DDTHH:mm' or ''. */
|
|
34
|
+
value: string;
|
|
35
|
+
onChange: (value: string) => void;
|
|
36
|
+
required?: boolean;
|
|
37
|
+
availability?: DateAvailabilityConstraints | null;
|
|
38
|
+
placeholder: string;
|
|
39
|
+
todayLabel: string;
|
|
40
|
+
clearLabel: string;
|
|
41
|
+
prevMonthLabel: string;
|
|
42
|
+
nextMonthLabel: string;
|
|
43
|
+
timeLabel: string;
|
|
44
|
+
closedLabel: string;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function splitValue(value: string): { date: string; time: string } {
|
|
49
|
+
const [date = '', time = ''] = value.split('T');
|
|
50
|
+
return { date, time: time.slice(0, 5) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function DateTimePicker({
|
|
54
|
+
id,
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
required,
|
|
58
|
+
availability,
|
|
59
|
+
placeholder,
|
|
60
|
+
todayLabel,
|
|
61
|
+
clearLabel,
|
|
62
|
+
prevMonthLabel,
|
|
63
|
+
nextMonthLabel,
|
|
64
|
+
timeLabel,
|
|
65
|
+
closedLabel,
|
|
66
|
+
className,
|
|
67
|
+
}: DateTimePickerProps) {
|
|
68
|
+
const submitted = splitValue(value);
|
|
69
|
+
// The date survives on its own while no time is picked yet. The field value
|
|
70
|
+
// stays empty until both halves exist, so a `required` field can't be
|
|
71
|
+
// satisfied by a half-filled answer.
|
|
72
|
+
const [date, setDate] = useState(submitted.date);
|
|
73
|
+
|
|
74
|
+
const slots = date ? computeAvailableSlots(availability, date) : [];
|
|
75
|
+
const windows = date ? getBusinessHoursForDate(availability, date) : [];
|
|
76
|
+
const openDay = (dateYYYYMMDD: string) =>
|
|
77
|
+
!availability?.businessHours?.length ||
|
|
78
|
+
getBusinessHoursForDate(availability, dateYYYYMMDD).length > 0;
|
|
79
|
+
|
|
80
|
+
const commit = (nextDate: string, nextTime: string) => {
|
|
81
|
+
setDate(nextDate);
|
|
82
|
+
onChange(nextDate && nextTime ? `${nextDate}T${nextTime}` : '');
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className={cn('space-y-2', className)}>
|
|
87
|
+
<DatePicker
|
|
88
|
+
id={id}
|
|
89
|
+
value={date}
|
|
90
|
+
onChange={(d) => commit(d, '')}
|
|
91
|
+
required={required}
|
|
92
|
+
availability={availability}
|
|
93
|
+
isDateSelectable={openDay}
|
|
94
|
+
placeholder={placeholder}
|
|
95
|
+
todayLabel={todayLabel}
|
|
96
|
+
clearLabel={clearLabel}
|
|
97
|
+
prevMonthLabel={prevMonthLabel}
|
|
98
|
+
nextMonthLabel={nextMonthLabel}
|
|
99
|
+
/>
|
|
100
|
+
|
|
101
|
+
{date && slots.length > 0 && (
|
|
102
|
+
<div className="flex flex-wrap gap-1.5" role="group" aria-label={timeLabel}>
|
|
103
|
+
{slots.map((slot) => (
|
|
104
|
+
<button
|
|
105
|
+
key={slot}
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => commit(date, slot)}
|
|
108
|
+
aria-pressed={submitted.time === slot}
|
|
109
|
+
className={cn(
|
|
110
|
+
'h-9 rounded-lg border px-3 text-sm tabular-nums transition-colors',
|
|
111
|
+
submitted.time === slot
|
|
112
|
+
? 'bg-primary text-primary-foreground border-primary font-semibold'
|
|
113
|
+
: 'border-border hover:bg-secondary'
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
{slot}
|
|
117
|
+
</button>
|
|
118
|
+
))}
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{date && slots.length === 0 && windows.length > 0 && (
|
|
123
|
+
<label className="flex items-center gap-2 text-sm">
|
|
124
|
+
<span className="text-muted-foreground">{timeLabel}</span>
|
|
125
|
+
<input
|
|
126
|
+
type="time"
|
|
127
|
+
value={submitted.time}
|
|
128
|
+
required={required}
|
|
129
|
+
// The window is a half-open interval [open, close) server-side, so
|
|
130
|
+
// `max` here is the last minute the API will still accept.
|
|
131
|
+
min={windows[0].open}
|
|
132
|
+
max={windows[0].close}
|
|
133
|
+
onChange={(e) => commit(date, e.target.value)}
|
|
134
|
+
className="bg-background focus-visible:ring-primary h-11 rounded-lg border px-3 text-sm focus-visible:outline-none focus-visible:ring-2"
|
|
135
|
+
/>
|
|
136
|
+
</label>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{date && slots.length === 0 && windows.length === 0 && (
|
|
140
|
+
<p className="text-muted-foreground text-xs">{closedLabel}</p>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -14,6 +14,21 @@ interface ShippingStepProps {
|
|
|
14
14
|
className?: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Live carrier rates arrive labelled with the carrier's own service identifier
|
|
19
|
+
* — `USPS PriorityMailInternational`, `USAExportPBA USAExportStandard` — which
|
|
20
|
+
* answers a question no shopper asked. They are choosing between how fast and
|
|
21
|
+
* how much, so we render `speedTier` in the store's own language instead.
|
|
22
|
+
*
|
|
23
|
+
* Manual zone rates carry no `speedTier`: the merchant named those deliberately,
|
|
24
|
+
* so those keep `rate.name` exactly as written.
|
|
25
|
+
*/
|
|
26
|
+
const TIER_KEYS = {
|
|
27
|
+
cheapest: 'shippingTierCheapest',
|
|
28
|
+
balanced: 'shippingTierBalanced',
|
|
29
|
+
fastest: 'shippingTierFastest',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
17
32
|
export function ShippingStep({
|
|
18
33
|
rates,
|
|
19
34
|
selectedRateId,
|
|
@@ -53,6 +68,7 @@ export function ShippingStep({
|
|
|
53
68
|
const price = parseFloat(rate.price);
|
|
54
69
|
const isFree = price === 0;
|
|
55
70
|
const isSelected = selectedRateId === rate.id;
|
|
71
|
+
const label = rate.speedTier ? t(TIER_KEYS[rate.speedTier]) : rate.name;
|
|
56
72
|
|
|
57
73
|
return (
|
|
58
74
|
<button
|
|
@@ -80,7 +96,7 @@ export function ShippingStep({
|
|
|
80
96
|
|
|
81
97
|
{/* Rate info */}
|
|
82
98
|
<div className="min-w-0 flex-1">
|
|
83
|
-
<p className="text-foreground text-sm font-medium">{
|
|
99
|
+
<p className="text-foreground text-sm font-medium">{label}</p>
|
|
84
100
|
{rate.description && (
|
|
85
101
|
<p className="text-muted-foreground mt-0.5 text-xs">{rate.description}</p>
|
|
86
102
|
)}
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"catEarrings": "Earrings",
|
|
31
31
|
"catBracelets": "Bracelets",
|
|
32
32
|
"catCta": "Shop the category",
|
|
33
|
-
"storyEyebrow": "Our
|
|
33
|
+
"storyEyebrow": "Our story",
|
|
34
34
|
"storyTitle": "Made with care, made to last",
|
|
35
|
-
"storyP1": "Every
|
|
36
|
-
"storyP2": "We believe in
|
|
35
|
+
"storyP1": "Every product we carry is chosen with the same question in mind: would we want this ourselves? We check quality, sourcing and detail before anything reaches our shelves, so what arrives at your door is exactly what you expect.",
|
|
36
|
+
"storyP2": "We believe in products that last: solid materials, careful quality checks and full support behind every order. That is what care for the details looks like.",
|
|
37
37
|
"storyCta": "Discover the collection",
|
|
38
|
-
"storyStat1Value": "
|
|
39
|
-
"storyStat1Label": "Every
|
|
40
|
-
"storyStat2Value": "
|
|
41
|
-
"storyStat2Label": "Every
|
|
38
|
+
"storyStat1Value": "Quality-checked",
|
|
39
|
+
"storyStat1Label": "Every product is checked before it's listed",
|
|
40
|
+
"storyStat2Value": "Fully guaranteed",
|
|
41
|
+
"storyStat2Label": "Every order backed by our support",
|
|
42
42
|
"testimonialsEyebrow": "Customer stories",
|
|
43
43
|
"testimonialsTitle": "They already fell in love",
|
|
44
44
|
"testimonial1Quote": "It arrived in gorgeous packaging and looks even better than the photos. I got compliments on day one.",
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
"catEarrings": "עגילים",
|
|
31
31
|
"catBracelets": "צמידים",
|
|
32
32
|
"catCta": "לצפייה בקטגוריה",
|
|
33
|
-
"storyEyebrow": "
|
|
34
|
-
"storyTitle": "
|
|
35
|
-
"storyP1": "כל
|
|
36
|
-
"storyP2": "אנחנו מאמינים
|
|
33
|
+
"storyEyebrow": "הסיפור שלנו",
|
|
34
|
+
"storyTitle": "מוקפד בכל פרט, מהתחלה ועד אליכם",
|
|
35
|
+
"storyP1": "כל מוצר אצלנו עובר את אותה שאלה: האם היינו רוצים אותו בעצמנו? אנחנו בודקים איכות, מקור וכל פרט לפני שמשהו עולה למדף שלנו, כדי שמה שמגיע אליכם יהיה בדיוק מה שציפיתם לו.",
|
|
36
|
+
"storyP2": "אנחנו מאמינים במוצרים שנשארים: חומרים איכותיים, בקרת איכות קפדנית ותמיכה מלאה מאחורי כל הזמנה. ככה נראית תשומת לב לפרטים הקטנים.",
|
|
37
37
|
"storyCta": "לגלות את הקולקציה",
|
|
38
|
-
"storyStat1Value": "
|
|
39
|
-
"storyStat1Label": "כל
|
|
40
|
-
"storyStat2Value": "
|
|
41
|
-
"storyStat2Label": "כל
|
|
38
|
+
"storyStat1Value": "איכות נבדקת",
|
|
39
|
+
"storyStat1Label": "כל מוצר נבדק לפני שהוא עולה לאתר",
|
|
40
|
+
"storyStat2Value": "אחריות מלאה",
|
|
41
|
+
"storyStat2Label": "כל הזמנה מגובה בתמיכה שלנו",
|
|
42
42
|
"testimonialsEyebrow": "לקוחות מספרות",
|
|
43
43
|
"testimonialsTitle": "הן כבר התאהבו",
|
|
44
44
|
"testimonial1Quote": "הפריט הגיע באריזה מהממת ונראה אפילו יותר טוב מבתמונות. קיבלתי מחמאות כבר ביום הראשון.",
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Editorial "our
|
|
4
|
+
* Editorial "our story" band — lifestyle photography (Pexels hotlinks,
|
|
5
5
|
* HEAD-verified) + brand-story copy from translation keys. Purely template
|
|
6
|
-
* marketing content; no catalog data is hardcoded here.
|
|
6
|
+
* marketing content; no catalog data is hardcoded here. Deliberately
|
|
7
|
+
* vertical-neutral (order fulfillment / packaging imagery, not a specific
|
|
8
|
+
* product category) since this pack scaffolds stores of any kind.
|
|
7
9
|
*/
|
|
8
10
|
import { Link } from '@/core/lib/navigation';
|
|
9
11
|
import { useTranslations } from '@/core/lib/translations';
|
|
@@ -11,9 +13,9 @@ import { IconArrowEnd } from '@/ui/shared/icons';
|
|
|
11
13
|
|
|
12
14
|
// Verified free-stock imagery (images.pexels.com allows hotlinking).
|
|
13
15
|
const IMG_MODEL =
|
|
14
|
-
'https://images.pexels.com/photos/
|
|
16
|
+
'https://images.pexels.com/photos/7857523/pexels-photo-7857523.jpeg?auto=compress&cs=tinysrgb&w=1200';
|
|
15
17
|
const IMG_DETAIL =
|
|
16
|
-
'https://images.pexels.com/photos/
|
|
18
|
+
'https://images.pexels.com/photos/6224252/pexels-photo-6224252.jpeg?auto=compress&cs=tinysrgb&w=800';
|
|
17
19
|
|
|
18
20
|
export function EditorialBand() {
|
|
19
21
|
const t = useTranslations('home');
|
|
@@ -23,8 +25,8 @@ export function EditorialBand() {
|
|
|
23
25
|
<div className="container-page grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
|
|
24
26
|
{/* Image pair */}
|
|
25
27
|
<div className="grid grid-cols-12 gap-4">
|
|
26
|
-
<figure className="col-span-8 -rotate-2 overflow-hidden rounded-3xl border-4
|
|
27
|
-
{
|
|
28
|
+
<figure className="border-background col-span-8 -rotate-2 overflow-hidden rounded-3xl border-4 shadow-[0_28px_60px_-24px_hsl(var(--primary)/0.35)]">
|
|
29
|
+
{}
|
|
28
30
|
<img
|
|
29
31
|
src={IMG_MODEL}
|
|
30
32
|
alt={t('storyTitle')}
|
|
@@ -32,8 +34,8 @@ export function EditorialBand() {
|
|
|
32
34
|
className="aspect-[4/5] h-full w-full object-cover"
|
|
33
35
|
/>
|
|
34
36
|
</figure>
|
|
35
|
-
<figure className="col-span-4 -mb-8 self-end
|
|
36
|
-
{
|
|
37
|
+
<figure className="border-background col-span-4 -mb-8 rotate-3 self-end overflow-hidden rounded-3xl border-4 shadow-[0_24px_50px_-20px_hsl(var(--primary)/0.35)]">
|
|
38
|
+
{}
|
|
37
39
|
<img
|
|
38
40
|
src={IMG_DETAIL}
|
|
39
41
|
alt={t('storyStat1Value')}
|
|
@@ -47,18 +49,18 @@ export function EditorialBand() {
|
|
|
47
49
|
<div className="max-w-xl">
|
|
48
50
|
<span className="eyebrow">{t('storyEyebrow')}</span>
|
|
49
51
|
<h2 className="text-4xl sm:text-5xl">{t('storyTitle')}</h2>
|
|
50
|
-
<p className="mt-5 leading-8
|
|
51
|
-
<p className="mt-4 leading-8
|
|
52
|
+
<p className="text-muted-foreground mt-5 leading-8">{t('storyP1')}</p>
|
|
53
|
+
<p className="text-muted-foreground mt-4 leading-8">{t('storyP2')}</p>
|
|
52
54
|
|
|
53
55
|
{/* Craft highlights */}
|
|
54
56
|
<dl className="mt-8 grid grid-cols-2 gap-6 border-t pt-8">
|
|
55
57
|
<div>
|
|
56
|
-
<dt className="font-display text-
|
|
57
|
-
<dd className="mt-1 text-sm
|
|
58
|
+
<dt className="font-display text-foreground text-2xl">{t('storyStat1Value')}</dt>
|
|
59
|
+
<dd className="text-muted-foreground mt-1 text-sm">{t('storyStat1Label')}</dd>
|
|
58
60
|
</div>
|
|
59
61
|
<div>
|
|
60
|
-
<dt className="font-display text-
|
|
61
|
-
<dd className="mt-1 text-sm
|
|
62
|
+
<dt className="font-display text-foreground text-2xl">{t('storyStat2Value')}</dt>
|
|
63
|
+
<dd className="text-muted-foreground mt-1 text-sm">{t('storyStat2Label')}</dd>
|
|
62
64
|
</div>
|
|
63
65
|
</dl>
|
|
64
66
|
|