@shop-on/create-theme 0.2.0 → 0.3.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/package.json +1 -1
- package/template/.claude/skills/theme-dev/recipes/customer-auth.md +15 -10
- package/template/package.json +10 -10
- package/template/src/route-adapter.tsx +2 -0
- package/template/src/routeTree.gen.ts +22 -0
- package/template/src/routes/__root.tsx +4 -1
- package/template/src/routes/{-$locale}/checkout.tsx +12 -0
- package/template/src/server/checkout-redirect.ts +21 -0
- package/template/src/server/customer.ts +2 -2
package/package.json
CHANGED
|
@@ -11,13 +11,13 @@ rather than reimplementing the package surface.
|
|
|
11
11
|
|
|
12
12
|
## The package surface (already built — consume, don't reimplement)
|
|
13
13
|
|
|
14
|
-
| Subpath | Gives you
|
|
15
|
-
| ---------------------------------------- |
|
|
16
|
-
| `@shop-on/theme-server/customer-session` | `read/write/clearCustomerSession` — encrypted `customer_session` cookie (HMAC label `"customer"` off `STOREFRONT_CUSTOMER_SESSION_SECRET`). **Reads never throw** (missing secret / bad cookie → `undefined` = anonymous); writes fail loudly
|
|
17
|
-
| `@shop-on/theme-server/customer` | `probeCurrentCustomer()` (3-branch: authoritative null → clears cookie; valid → returns customer, NO cookie rewrite; thrown → preserves cookie, null) + `revokeCustomerSession()`
|
|
18
|
-
| `@shop-on/theme-server/customer-handoff` | `consumeCustomerHandoff(nonce)` — consumes the one-time `customer_session_handoff` nonce, cross-shop-guards on `STORE_SLUG`, writes the cookie. A thrown internal call degrades to `{ok:false, reason:"consume_error"}` — never lets an API blip error the landing page
|
|
19
|
-
| `@shop-on/theme-server/account` | `getAccountRedirectUrl({subpath?, returnToPath?})` (validated + absolutized return_to), `normalizeReturnToPath`, `handleCustomerLogoutPost(request)` (the ENTIRE logout POST: Origin gate → revoke → clear both cookies → auto-submit form), `renderLogoutForm`
|
|
20
|
-
| `@shop-on/theme-server/cart-customer` | `resolveCartForCustomer({session, customerToken, locale?})` (own cart as-is / attach anonymous cart / claim stale-or-fresh-device cart) + `mintOrClaimCart({session, customerToken?})` (claim-before-create; anonymous = straight mint). Transient transport failures PRESERVE the guest-cart cookie — only authoritative API answers claim or clear
|
|
14
|
+
| Subpath | Gives you |
|
|
15
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
16
|
+
| `@shop-on/theme-server/customer-session` | `read/write/clearCustomerSession` — encrypted `customer_session` cookie (HMAC label `"customer"` off `STOREFRONT_CUSTOMER_SESSION_SECRET`). **Reads never throw** (missing secret / bad cookie → `undefined` = anonymous); writes fail loudly |
|
|
17
|
+
| `@shop-on/theme-server/customer` | `probeCurrentCustomer()` (3-branch: authoritative null → clears cookie; valid → returns customer, NO cookie rewrite; thrown → preserves cookie, null) + `revokeCustomerSession()` |
|
|
18
|
+
| `@shop-on/theme-server/customer-handoff` | `consumeCustomerHandoff(nonce, state)` — checks `state` (the URL's `?state=`) against the `account_state` cookie `getAccountRedirectUrl` set, then consumes the one-time `customer_session_handoff` nonce, cross-shop-guards on `STORE_SLUG`, writes the cookie. A thrown internal call degrades to `{ok:false, reason:"consume_error"}` — never lets an API blip error the landing page |
|
|
19
|
+
| `@shop-on/theme-server/account` | `getAccountRedirectUrl({subpath?, returnToPath?})` (validated + absolutized return_to, with a fresh `state` bound to the `account_state` cookie), `normalizeReturnToPath`, `handleCustomerLogoutPost(request)` (the ENTIRE logout POST: Origin gate → revoke → clear both cookies → auto-submit form), `renderLogoutForm` |
|
|
20
|
+
| `@shop-on/theme-server/cart-customer` | `resolveCartForCustomer({session, customerToken, locale?})` (own cart as-is / attach anonymous cart / claim stale-or-fresh-device cart) + `mintOrClaimCart({session, customerToken?})` (claim-before-create; anonymous = straight mint). Transient transport failures PRESERVE the guest-cart cookie — only authoritative API answers claim or clear |
|
|
21
21
|
|
|
22
22
|
Env: `STORE_SLUG` + `STOREFRONT_CUSTOMER_SESSION_SECRET` (injected by the platform
|
|
23
23
|
in prod; `.env` in dev, copied from `.env.example`). No new theme deps — `@shop-on/customer-runtime`
|
|
@@ -41,7 +41,8 @@ rides inside theme-server.
|
|
|
41
41
|
`return handleCustomerLogoutPost(request)` (dynamic import).
|
|
42
42
|
4. **Root route** — `validateSearch` gains `customer_handoff` +
|
|
43
43
|
`clear_customer_session` (string passthrough); `beforeLoad` consumes the
|
|
44
|
-
nonce
|
|
44
|
+
nonce, passing `location.search.state` beside it (without it the handoff is
|
|
45
|
+
dropped unconsumed and the buyer lands signed out), then redirect-replaces to the stripped URL (use the RAW
|
|
45
46
|
`location.searchStr` so child-route params survive); loader starts
|
|
46
47
|
`probeCurrentCustomerFn()` in wave 1 and **chains the cart off the probe**
|
|
47
48
|
(`customerPromise.then(c => resolveCartFn({customerId: c?.id ?? null}))`) so
|
|
@@ -50,8 +51,12 @@ rides inside theme-server.
|
|
|
50
51
|
`resolveCart` branches anonymous-read vs `resolveCartForCustomer`;
|
|
51
52
|
cookie-less adds go through `mintOrClaimCart({session, customerToken})`
|
|
52
53
|
(handles anonymous too); forward `customerToken` into every shared op.
|
|
53
|
-
Checkout:
|
|
54
|
-
|
|
54
|
+
Checkout: the `/checkout` route reads `checkout_state` in `validateSearch`
|
|
55
|
+
and passes it to `startCheckoutHandoff({ state, customerToken })` (`state`
|
|
56
|
+
is a required key). Without a state it returns checkout's `/begin`, which
|
|
57
|
+
binds the browser and comes back with one; with it, it mints (the token lets
|
|
58
|
+
the API fold the buyer into the handoff). Checkout rejects a handoff minted
|
|
59
|
+
without the state.
|
|
55
60
|
6. **Header UI** — an `AccountControl` snippet: anonymous = icon-link to
|
|
56
61
|
`/account?return_to=…`; signed-in = **lazy** dropdown chunk (keep the menu
|
|
57
62
|
primitive out of the anonymous bundle). Merchant `account_menu` `link_list`
|
package/template/package.json
CHANGED
|
@@ -17,14 +17,14 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@bprogress/core": "1.3.4",
|
|
20
|
-
"@shop-on/i18n": "^0.
|
|
21
|
-
"@shop-on/theme-contract": "^0.
|
|
22
|
-
"@shop-on/theme-loader": "^0.
|
|
23
|
-
"@shop-on/theme-react": "^0.
|
|
24
|
-
"@shop-on/theme-routes": "^0.
|
|
25
|
-
"@shop-on/theme-runtime": "^0.
|
|
26
|
-
"@shop-on/theme-server": "^0.
|
|
27
|
-
"@shop-on/theme-system": "^0.
|
|
20
|
+
"@shop-on/i18n": "^0.3.0",
|
|
21
|
+
"@shop-on/theme-contract": "^0.3.0",
|
|
22
|
+
"@shop-on/theme-loader": "^0.3.0",
|
|
23
|
+
"@shop-on/theme-react": "^0.3.0",
|
|
24
|
+
"@shop-on/theme-routes": "^0.3.0",
|
|
25
|
+
"@shop-on/theme-runtime": "^0.3.0",
|
|
26
|
+
"@shop-on/theme-server": "^0.3.0",
|
|
27
|
+
"@shop-on/theme-system": "^0.3.0",
|
|
28
28
|
"@tailwindcss/vite": "4.3.3",
|
|
29
29
|
"@tanstack/react-router": "1.170.45",
|
|
30
30
|
"@tanstack/react-start": "1.169.3",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"tailwindcss": "4.3.3"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@shop-on/theme-config": "^0.
|
|
39
|
-
"@shop-on/theme-dev-host": "^0.
|
|
38
|
+
"@shop-on/theme-config": "^0.3.0",
|
|
39
|
+
"@shop-on/theme-dev-host": "^0.3.0",
|
|
40
40
|
"@spiriit/vite-plugin-svg-spritemap": "7.2.0",
|
|
41
41
|
"@types/node": "26.6.5",
|
|
42
42
|
"@types/react": "19.3.0",
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "@/src/lib/listing-search";
|
|
20
20
|
import { isPreviewSearch, paginatedPath, templateSeoHead } from "@/src/lib/seo-head";
|
|
21
21
|
import { getAccountRedirectUrlFn } from "@/src/server/customer";
|
|
22
|
+
import { startCheckoutFn } from "@/src/server/checkout-redirect";
|
|
22
23
|
import {
|
|
23
24
|
getArticleTemplateFn,
|
|
24
25
|
getBlogPageTemplateFn,
|
|
@@ -70,6 +71,7 @@ export const routeAdapter: ThemeRouteAdapter = {
|
|
|
70
71
|
parseBlogSearch,
|
|
71
72
|
},
|
|
72
73
|
account: { getAccountRedirectUrlFn },
|
|
74
|
+
checkout: { startCheckoutFn },
|
|
73
75
|
// Add a `Wrapper` here if sections need to know they're in a picker preview.
|
|
74
76
|
preview: { Unavailable: PreviewUnavailable },
|
|
75
77
|
};
|
|
@@ -19,6 +19,7 @@ import { Route as AnalyticsVisitsRouteImport } from './routes/analytics.visits'
|
|
|
19
19
|
import { Route as Char123LocaleChar125IndexRouteImport } from './routes/{-$locale}/index'
|
|
20
20
|
import { Route as Char123LocaleChar125404RouteImport } from './routes/{-$locale}/404'
|
|
21
21
|
import { Route as Char123LocaleChar125CartRouteImport } from './routes/{-$locale}/cart'
|
|
22
|
+
import { Route as Char123LocaleChar125CheckoutRouteImport } from './routes/{-$locale}/checkout'
|
|
22
23
|
import { Route as Char123LocaleChar125PasswordRouteImport } from './routes/{-$locale}/password'
|
|
23
24
|
import { Route as Char123LocaleChar125SearchRouteImport } from './routes/{-$locale}/search'
|
|
24
25
|
import { Route as Char123LocaleChar125AccountIndexRouteImport } from './routes/{-$locale}/account/index'
|
|
@@ -87,6 +88,12 @@ const Char123LocaleChar125CartRoute =
|
|
|
87
88
|
path: '/cart',
|
|
88
89
|
getParentRoute: () => Char123LocaleChar125Route,
|
|
89
90
|
} as any)
|
|
91
|
+
const Char123LocaleChar125CheckoutRoute =
|
|
92
|
+
Char123LocaleChar125CheckoutRouteImport.update({
|
|
93
|
+
id: '/checkout',
|
|
94
|
+
path: '/checkout',
|
|
95
|
+
getParentRoute: () => Char123LocaleChar125Route,
|
|
96
|
+
} as any)
|
|
90
97
|
const Char123LocaleChar125PasswordRoute =
|
|
91
98
|
Char123LocaleChar125PasswordRouteImport.update({
|
|
92
99
|
id: '/password',
|
|
@@ -190,6 +197,7 @@ export interface FileRoutesByFullPath {
|
|
|
190
197
|
'/analytics/visits': typeof AnalyticsVisitsRoute
|
|
191
198
|
'/{-$locale}/404': typeof Char123LocaleChar125404Route
|
|
192
199
|
'/{-$locale}/cart': typeof Char123LocaleChar125CartRoute
|
|
200
|
+
'/{-$locale}/checkout': typeof Char123LocaleChar125CheckoutRoute
|
|
193
201
|
'/{-$locale}/password': typeof Char123LocaleChar125PasswordRoute
|
|
194
202
|
'/{-$locale}/search': typeof Char123LocaleChar125SearchRoute
|
|
195
203
|
'/{-$locale}/': typeof Char123LocaleChar125IndexRoute
|
|
@@ -216,6 +224,7 @@ export interface FileRoutesByTo {
|
|
|
216
224
|
'/analytics/visits': typeof AnalyticsVisitsRoute
|
|
217
225
|
'/{-$locale}/404': typeof Char123LocaleChar125404Route
|
|
218
226
|
'/{-$locale}/cart': typeof Char123LocaleChar125CartRoute
|
|
227
|
+
'/{-$locale}/checkout': typeof Char123LocaleChar125CheckoutRoute
|
|
219
228
|
'/{-$locale}/password': typeof Char123LocaleChar125PasswordRoute
|
|
220
229
|
'/{-$locale}/search': typeof Char123LocaleChar125SearchRoute
|
|
221
230
|
'/{-$locale}': typeof Char123LocaleChar125IndexRoute
|
|
@@ -244,6 +253,7 @@ export interface FileRoutesById {
|
|
|
244
253
|
'/analytics/visits': typeof AnalyticsVisitsRoute
|
|
245
254
|
'/{-$locale}/404': typeof Char123LocaleChar125404Route
|
|
246
255
|
'/{-$locale}/cart': typeof Char123LocaleChar125CartRoute
|
|
256
|
+
'/{-$locale}/checkout': typeof Char123LocaleChar125CheckoutRoute
|
|
247
257
|
'/{-$locale}/password': typeof Char123LocaleChar125PasswordRoute
|
|
248
258
|
'/{-$locale}/search': typeof Char123LocaleChar125SearchRoute
|
|
249
259
|
'/{-$locale}/': typeof Char123LocaleChar125IndexRoute
|
|
@@ -273,6 +283,7 @@ export interface FileRouteTypes {
|
|
|
273
283
|
| '/analytics/visits'
|
|
274
284
|
| '/{-$locale}/404'
|
|
275
285
|
| '/{-$locale}/cart'
|
|
286
|
+
| '/{-$locale}/checkout'
|
|
276
287
|
| '/{-$locale}/password'
|
|
277
288
|
| '/{-$locale}/search'
|
|
278
289
|
| '/{-$locale}/'
|
|
@@ -299,6 +310,7 @@ export interface FileRouteTypes {
|
|
|
299
310
|
| '/analytics/visits'
|
|
300
311
|
| '/{-$locale}/404'
|
|
301
312
|
| '/{-$locale}/cart'
|
|
313
|
+
| '/{-$locale}/checkout'
|
|
302
314
|
| '/{-$locale}/password'
|
|
303
315
|
| '/{-$locale}/search'
|
|
304
316
|
| '/{-$locale}'
|
|
@@ -326,6 +338,7 @@ export interface FileRouteTypes {
|
|
|
326
338
|
| '/analytics/visits'
|
|
327
339
|
| '/{-$locale}/404'
|
|
328
340
|
| '/{-$locale}/cart'
|
|
341
|
+
| '/{-$locale}/checkout'
|
|
329
342
|
| '/{-$locale}/password'
|
|
330
343
|
| '/{-$locale}/search'
|
|
331
344
|
| '/{-$locale}/'
|
|
@@ -426,6 +439,13 @@ declare module '@tanstack/react-router' {
|
|
|
426
439
|
preLoaderRoute: typeof Char123LocaleChar125CartRouteImport
|
|
427
440
|
parentRoute: typeof Char123LocaleChar125Route
|
|
428
441
|
}
|
|
442
|
+
'/{-$locale}/checkout': {
|
|
443
|
+
id: '/{-$locale}/checkout'
|
|
444
|
+
path: '/checkout'
|
|
445
|
+
fullPath: '/{-$locale}/checkout'
|
|
446
|
+
preLoaderRoute: typeof Char123LocaleChar125CheckoutRouteImport
|
|
447
|
+
parentRoute: typeof Char123LocaleChar125Route
|
|
448
|
+
}
|
|
429
449
|
'/{-$locale}/password': {
|
|
430
450
|
id: '/{-$locale}/password'
|
|
431
451
|
path: '/password'
|
|
@@ -537,6 +557,7 @@ declare module '@tanstack/react-router' {
|
|
|
537
557
|
interface Char123LocaleChar125RouteChildren {
|
|
538
558
|
Char123LocaleChar125404Route: typeof Char123LocaleChar125404Route
|
|
539
559
|
Char123LocaleChar125CartRoute: typeof Char123LocaleChar125CartRoute
|
|
560
|
+
Char123LocaleChar125CheckoutRoute: typeof Char123LocaleChar125CheckoutRoute
|
|
540
561
|
Char123LocaleChar125PasswordRoute: typeof Char123LocaleChar125PasswordRoute
|
|
541
562
|
Char123LocaleChar125SearchRoute: typeof Char123LocaleChar125SearchRoute
|
|
542
563
|
Char123LocaleChar125IndexRoute: typeof Char123LocaleChar125IndexRoute
|
|
@@ -558,6 +579,7 @@ interface Char123LocaleChar125RouteChildren {
|
|
|
558
579
|
const Char123LocaleChar125RouteChildren: Char123LocaleChar125RouteChildren = {
|
|
559
580
|
Char123LocaleChar125404Route: Char123LocaleChar125404Route,
|
|
560
581
|
Char123LocaleChar125CartRoute: Char123LocaleChar125CartRoute,
|
|
582
|
+
Char123LocaleChar125CheckoutRoute: Char123LocaleChar125CheckoutRoute,
|
|
561
583
|
Char123LocaleChar125PasswordRoute: Char123LocaleChar125PasswordRoute,
|
|
562
584
|
Char123LocaleChar125SearchRoute: Char123LocaleChar125SearchRoute,
|
|
563
585
|
Char123LocaleChar125IndexRoute: Char123LocaleChar125IndexRoute,
|
|
@@ -70,9 +70,12 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|
|
70
70
|
const search = location.search as {
|
|
71
71
|
customer_handoff?: string;
|
|
72
72
|
clear_customer_session?: string;
|
|
73
|
+
state?: string;
|
|
73
74
|
};
|
|
74
75
|
if (search.customer_handoff) {
|
|
75
|
-
await consumeCustomerHandoffFn({
|
|
76
|
+
await consumeCustomerHandoffFn({
|
|
77
|
+
data: { nonce: search.customer_handoff, state: search.state },
|
|
78
|
+
});
|
|
76
79
|
}
|
|
77
80
|
if (!search.customer_handoff && !search.clear_customer_session) return;
|
|
78
81
|
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
import { checkoutRouteOptions } from "@shop-on/theme-routes";
|
|
3
|
+
import { routeAdapter } from "@/src/route-adapter";
|
|
4
|
+
|
|
5
|
+
// The cart → checkout bounce, via checkout's `/begin` on the first visit. See
|
|
6
|
+
// docs/theme-cart.md#checkout-handoff.
|
|
7
|
+
const { validateSearch, beforeLoad } = checkoutRouteOptions(routeAdapter.checkout);
|
|
8
|
+
|
|
9
|
+
export const Route = createFileRoute("/{-$locale}/checkout")({
|
|
10
|
+
validateSearch,
|
|
11
|
+
beforeLoad,
|
|
12
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Server-side cart→checkout bounce. The cart-cookie read + `cartCheckout` call
|
|
2
|
+
// + result mapping live in the shared `@shop-on/theme-server/checkout` helper;
|
|
3
|
+
// this forwards the buyer token so a signed-in checkout arrives prefilled
|
|
4
|
+
// (anonymous buyers forward nothing). The node-only helpers are dynamic-
|
|
5
|
+
// imported inside the handler so this module stays safe to import from the
|
|
6
|
+
// client-side `/checkout` route.
|
|
7
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
8
|
+
import type { CheckoutHandoffResult } from "@shop-on/theme-server/checkout";
|
|
9
|
+
|
|
10
|
+
export type { CheckoutHandoffResult };
|
|
11
|
+
|
|
12
|
+
// `state` is the `checkout_state` checkout's `/begin` sent back; without one the
|
|
13
|
+
// result is the `/begin` URL itself (what a browser-side caller gets).
|
|
14
|
+
export const startCheckoutFn = createServerFn({ method: "POST" })
|
|
15
|
+
.validator((data: { state?: string } | undefined) => ({ state: data?.state }))
|
|
16
|
+
.handler(async ({ data }): Promise<CheckoutHandoffResult> => {
|
|
17
|
+
const { readCustomerSession } = await import("@shop-on/theme-server/customer-session");
|
|
18
|
+
const { startCheckoutHandoff } = await import("@shop-on/theme-server/checkout");
|
|
19
|
+
const session = await readCustomerSession();
|
|
20
|
+
return startCheckoutHandoff({ state: data.state, customerToken: session?.token });
|
|
21
|
+
});
|
|
@@ -32,10 +32,10 @@ export const probeCurrentCustomerFn = createServerFn({ method: "GET" }).handler(
|
|
|
32
32
|
// Consume a `customer_session_handoff` nonce (root `beforeLoad`), writing the
|
|
33
33
|
// `customer_session` cookie. Cross-shop-guarded against `STORE_SLUG`.
|
|
34
34
|
export const consumeCustomerHandoffFn = createServerFn({ method: "POST" })
|
|
35
|
-
.validator((data: { nonce: string }) => data)
|
|
35
|
+
.validator((data: { nonce: string; state?: string }) => data)
|
|
36
36
|
.handler(async ({ data }): Promise<ConsumeCustomerHandoffResult> => {
|
|
37
37
|
const { consumeCustomerHandoff } = await import("@shop-on/theme-server/customer-handoff");
|
|
38
|
-
return consumeCustomerHandoff(data.nonce);
|
|
38
|
+
return consumeCustomerHandoff(data.nonce, data.state);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
// Build the central account-app URL the `/account/*` bouncers redirect to,
|