@01.software/init 0.10.0 → 0.10.1

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/ai-docs.js CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,5 +1,23 @@
1
1
  # ecommerce
2
2
 
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [81ef105]
8
+ - Updated dependencies [9789528]
9
+ - Updated dependencies [9b7afab]
10
+ - Updated dependencies [db8bd4b]
11
+ - Updated dependencies [b95f6ac]
12
+ - Updated dependencies [c60ec7f]
13
+ - Updated dependencies [6c71a71]
14
+ - Updated dependencies [3aa6239]
15
+ - Updated dependencies [5d7b26f]
16
+ - Updated dependencies [294b7d1]
17
+ - Updated dependencies [f87fe09]
18
+ - Updated dependencies [4362b14]
19
+ - @01.software/sdk@0.43.0
20
+
3
21
  ## 0.1.1
4
22
 
5
23
  ### Patch Changes
@@ -21,7 +21,13 @@ provider for a generated app is recorded in `app-config.ts` and its keys live in
21
21
  TossPayments payment REST API on the server.
22
22
  - A theming seam: a CSS-variable token layer in `app/globals.css` plus Tailwind.
23
23
  - Server-side order pricing, stock checks, and payment sync invariants.
24
- - Local persisted cart state with Zustand.
24
+ - Server-authoritative cart operations through the template's own route handlers,
25
+ backed by `commerce.cart.*`.
26
+ - An HttpOnly cart cookie containing only the unguessable `cartToken`; client
27
+ JavaScript receives rendered cart views but never owns the authoritative cart
28
+ or token, and no cart contents are persisted in localStorage.
29
+ - Checkout through `orders.checkout({ cartId })`, with orders resolved after
30
+ payment by `orders.getByPaymentId`.
25
31
 
26
32
  ## Scaffolding vs. this in-repo source
27
33
 
@@ -44,13 +50,13 @@ pnpm dev
44
50
  Without SDK or payment credentials, the app uses demo providers:
45
51
 
46
52
  ```bash
47
- # 01.software keys omitted -> demo catalog
53
+ # 01.software keys omitted -> demo catalog plus in-memory cart/checkout
48
54
  # payment keys omitted -> local demo payment completion
49
55
  ```
50
56
 
51
- Mock orders are persisted to `.mock-orders.json` so redirect and success routes
52
- can observe the same order during local development. This file is ignored and is
53
- not a production store.
57
+ The mock adapter emulates the server cart and checkout flow in memory for
58
+ zero-backend demos. Redirect and success routes resolve the same in-process
59
+ checkout model without writing a local order index.
54
60
 
55
61
  To use the Console ecommerce SDK adapter, set both SDK keys:
56
62
 
@@ -63,10 +69,11 @@ SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT=100000
63
69
  ```
64
70
 
65
71
  `SOFTWARE_API_URL`, `SOFTWARE_SHIPPING_AMOUNT`, and
66
- `SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT` are optional. The SDK adapter resolves
67
- payment return/webhook requests through the SDK `transactions` collection first,
68
- then falls back to a local `.software-orders.json` development index. This file
69
- is ignored and is not a production store.
72
+ `SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT` are optional. The SDK adapter uses Console
73
+ cart and order APIs for the full server-authoritative flow: route handlers call
74
+ `commerce.cart.*`, checkout calls `orders.checkout({ cartId })`, and
75
+ return/webhook/success reconciliation resolves orders by payment id through
76
+ `orders.getByPaymentId`.
70
77
 
71
78
  ## Payment provider keys
72
79
 
@@ -96,9 +103,12 @@ NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY=customer_...
96
103
 
97
104
  `NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY` is optional. If omitted, the browser SDK
98
105
  uses TossPayments' anonymous customer key. TossPayments redirects back with
99
- `paymentKey`, `orderId`, and `amount`; the template validates the returned
100
- amount against the stored order and confirms the payment on
101
- `/api/checkout/payment-return` before showing the success page.
106
+ `paymentKey`, `orderId`, and `amount`; `/api/checkout/payment-return` re-fetches
107
+ the provider payment, rejects a tampered redirect amount, captures with the
108
+ PG-verified amount, then redirects to the success page. Success-page
109
+ reconciliation re-fetches the provider payment again and places the Console
110
+ checkout through `orders.confirmPayment`, where the open checkout quote remains
111
+ the authoritative amount check.
102
112
  `TOSSPAYMENTS_API_BASE_URL` is optional and defaults to
103
113
  `https://api.tosspayments.com/v1`.
104
114
 
@@ -133,7 +143,12 @@ pnpm build
133
143
 
134
144
  ## Important boundary
135
145
 
136
- The cart stores only `variantId` and `quantity`. Checkout sends those lines plus
137
- customer and shipping details to the server; server code re-fetches variants,
138
- checks stock, recomputes subtotal/shipping/total, creates a pending order, and
139
- then synchronizes payment state.
146
+ The browser never owns the authoritative cart or cart capability tokens. It
147
+ talks to the template's cart route handlers, which keep the cart
148
+ server-authoritative and store only an HttpOnly cart cookie in the browser.
149
+ Client components may keep rendered cart views in memory, but cart mutations and
150
+ checkout always return to the server cart. Checkout attaches customer and
151
+ shipping details to that server cart, converts it with
152
+ `orders.checkout({ cartId })`, and then synchronizes payment state. After
153
+ payment, order lookup uses `orders.getByPaymentId`, so the template does not
154
+ need a file-backed order index.
@@ -1,22 +1,25 @@
1
- "use client";
1
+ 'use client'
2
2
 
3
- import Image from "next/image";
4
- import Link from "next/link";
5
- import { useCart } from "@/lib/cart/use-cart";
6
- import { formatMoney } from "@/lib/format";
3
+ import Image from 'next/image'
4
+ import Link from 'next/link'
5
+ import { useCart } from '@/lib/cart/use-cart'
6
+ import { formatMoney } from '@/lib/format'
7
7
 
8
8
  export function CartContent() {
9
- const { cart, loading, updateItem, removeItem } = useCart();
9
+ const { cart, loading, updateItem, removeItem } = useCart()
10
10
 
11
11
  if (loading && !cart) {
12
12
  return (
13
13
  <section>
14
14
  <h1>Loading your cart…</h1>
15
15
  </section>
16
- );
16
+ )
17
17
  }
18
18
 
19
- const items = cart?.items ?? [];
19
+ const items = cart?.items ?? []
20
+ const hasShippableItems = items.some(
21
+ (item) => item.requiresShipping !== false,
22
+ )
20
23
 
21
24
  if (items.length === 0) {
22
25
  return (
@@ -25,7 +28,7 @@ export function CartContent() {
25
28
  <p>Add products before starting a payment check.</p>
26
29
  <Link href="/products">Browse products</Link>
27
30
  </section>
28
- );
31
+ )
29
32
  }
30
33
 
31
34
  return (
@@ -104,16 +107,23 @@ export function CartContent() {
104
107
  <dd>
105
108
  <output>{formatMoney(cart?.subtotalAmount ?? 0)}</output>
106
109
  </dd>
107
- <dt>Shipping</dt>
108
- <dd>{formatMoney(cart?.shippingAmount ?? 0)}</dd>
110
+ {hasShippableItems ? (
111
+ <>
112
+ <dt>Shipping</dt>
113
+ <dd>{formatMoney(cart?.shippingAmount ?? 0)}</dd>
114
+ </>
115
+ ) : null}
109
116
  <dt>Total</dt>
110
117
  <dd>
111
118
  <output>{formatMoney(cart?.totalAmount ?? 0)}</output>
112
119
  </dd>
113
120
  </dl>
114
- <p>Totals are computed by the server cart and confirmed again at checkout.</p>
121
+ <p>
122
+ Totals are computed by the server cart and confirmed again at
123
+ checkout.
124
+ </p>
115
125
  <Link href="/checkout">Checkout</Link>
116
126
  </aside>
117
127
  </div>
118
- );
128
+ )
119
129
  }
@@ -1,91 +1,99 @@
1
- "use client";
1
+ 'use client'
2
2
 
3
- import Link from "next/link";
4
- import { useRouter } from "next/navigation";
5
- import { useState } from "react";
6
- import { paymentProviderLabel } from "@/app-config";
7
- import { useCart } from "@/lib/cart/use-cart";
8
- import type { CustomerSnapshot, ShippingAddress } from "@/lib/commerce/types";
9
- import type { ClientPaymentRequest } from "@/lib/payment/types";
3
+ import Link from 'next/link'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useState } from 'react'
6
+ import { paymentProviderLabel } from '@/app-config'
7
+ import { useCart } from '@/lib/cart/use-cart'
8
+ import type { CustomerSnapshot, ShippingAddress } from '@/lib/commerce/types'
9
+ import type { ClientPaymentRequest } from '@/lib/payment/types'
10
10
 
11
11
  type CheckoutResponse = {
12
- orderNumber: string;
13
- paymentId: string;
14
- amount: number;
15
- currency: string;
16
- redirectUrl?: string;
17
- clientPayment?: ClientPaymentRequest;
18
- };
12
+ orderNumber: string
13
+ paymentId: string
14
+ amount: number
15
+ currency: string
16
+ redirectUrl?: string
17
+ clientPayment?: ClientPaymentRequest
18
+ }
19
19
 
20
20
  export function CheckoutForm() {
21
- const router = useRouter();
22
- const { cart, loading, reset } = useCart();
23
- const [error, setError] = useState<string | null>(null);
24
- const [submitting, setSubmitting] = useState(false);
25
- const providerLabel = paymentProviderLabel();
21
+ const router = useRouter()
22
+ const { cart, loading, reset } = useCart()
23
+ const [error, setError] = useState<string | null>(null)
24
+ const [submitting, setSubmitting] = useState(false)
25
+ const providerLabel = paymentProviderLabel()
26
+ const hasShippableItems = !cart
27
+ ? true
28
+ : cart.items.some((item) => item.requiresShipping !== false)
26
29
 
27
30
  async function submit(formData: FormData) {
28
- setSubmitting(true);
29
- setError(null);
31
+ setSubmitting(true)
32
+ setError(null)
30
33
 
31
34
  const customerSnapshot: CustomerSnapshot = {
32
- name: value(formData, "customerName"),
33
- email: value(formData, "email"),
34
- phone: value(formData, "phone"),
35
- };
36
- const shippingAddress: ShippingAddress = {
37
- recipientName: value(formData, "recipientName"),
38
- phone: value(formData, "shippingPhone"),
39
- postalCode: value(formData, "postalCode"),
40
- address: value(formData, "address"),
41
- detailAddress: value(formData, "detailAddress"),
42
- deliveryMessage: value(formData, "deliveryMessage"),
43
- };
35
+ name: value(formData, 'customerName'),
36
+ email: value(formData, 'email'),
37
+ phone: value(formData, 'phone'),
38
+ }
39
+ const shippingAddress: ShippingAddress | undefined = hasShippableItems
40
+ ? {
41
+ recipientName: value(formData, 'recipientName'),
42
+ phone: value(formData, 'shippingPhone'),
43
+ postalCode: value(formData, 'postalCode'),
44
+ address: value(formData, 'address'),
45
+ detailAddress: value(formData, 'detailAddress'),
46
+ deliveryMessage: value(formData, 'deliveryMessage'),
47
+ }
48
+ : undefined
44
49
 
45
- const response = await fetch("/api/checkout", {
46
- method: "POST",
47
- headers: { "Content-Type": "application/json" },
48
- body: JSON.stringify({ customerSnapshot, shippingAddress }),
49
- });
50
+ const response = await fetch('/api/checkout', {
51
+ method: 'POST',
52
+ headers: { 'Content-Type': 'application/json' },
53
+ body: JSON.stringify({
54
+ customerSnapshot,
55
+ ...(shippingAddress ? { shippingAddress } : {}),
56
+ }),
57
+ })
50
58
 
51
59
  if (!response.ok) {
52
- const payload = (await response.json()) as { message?: string };
53
- setError(payload.message ?? "Checkout failed");
54
- setSubmitting(false);
55
- return;
60
+ const payload = (await response.json()) as { message?: string }
61
+ setError(payload.message ?? 'Checkout failed')
62
+ setSubmitting(false)
63
+ return
56
64
  }
57
65
 
58
- const payload = (await response.json()) as CheckoutResponse;
66
+ const payload = (await response.json()) as CheckoutResponse
59
67
  // scaffold:provider:portone:start
60
- if (payload.clientPayment?.provider === "portone") {
68
+ if (payload.clientPayment?.provider === 'portone') {
61
69
  try {
62
- await requestPortOnePayment(payload.clientPayment);
63
- reset();
70
+ await requestPortOnePayment(payload.clientPayment)
71
+ reset()
64
72
  } catch (error) {
65
- setError(error instanceof Error ? error.message : "Payment failed");
66
- setSubmitting(false);
73
+ setError(error instanceof Error ? error.message : 'Payment failed')
74
+ setSubmitting(false)
67
75
  }
68
- return;
76
+ return
69
77
  }
70
78
  // scaffold:provider:portone:end
71
79
  // scaffold:provider:tosspayments:start
72
- if (payload.clientPayment?.provider === "tosspayments") {
80
+ if (payload.clientPayment?.provider === 'tosspayments') {
73
81
  try {
74
- await requestTossPayments(payload.clientPayment);
75
- reset();
82
+ await requestTossPayments(payload.clientPayment)
83
+ reset()
76
84
  } catch (error) {
77
- setError(error instanceof Error ? error.message : "Payment failed");
78
- setSubmitting(false);
85
+ setError(error instanceof Error ? error.message : 'Payment failed')
86
+ setSubmitting(false)
79
87
  }
80
- return;
88
+ return
81
89
  }
82
90
  // scaffold:provider:tosspayments:end
83
91
 
84
- reset();
92
+ reset()
85
93
  router.push(
86
94
  payload.redirectUrl ??
87
95
  `/checkout/success?paymentId=${encodeURIComponent(payload.paymentId)}&orderNumber=${encodeURIComponent(payload.orderNumber)}`,
88
- );
96
+ )
89
97
  }
90
98
 
91
99
  if (!loading && (!cart || cart.items.length === 0)) {
@@ -95,7 +103,7 @@ export function CheckoutForm() {
95
103
  <p>Add at least one item before checkout.</p>
96
104
  <Link href="/products">Browse products</Link>
97
105
  </section>
98
- );
106
+ )
99
107
  }
100
108
 
101
109
  return (
@@ -103,31 +111,57 @@ export function CheckoutForm() {
103
111
  <section aria-labelledby="checkout-title">
104
112
  <h1 id="checkout-title">Review order details</h1>
105
113
  <p id="checkout-summary">
106
- Customer and shipping details are collected before payment handoff.
114
+ Customer details are collected before payment handoff.
107
115
  </p>
108
116
 
109
117
  <fieldset>
110
118
  <legend>Customer</legend>
111
- <Field name="customerName" label="Customer name" autoComplete="name" />
119
+ <Field
120
+ name="customerName"
121
+ label="Customer name"
122
+ autoComplete="name"
123
+ />
112
124
  <Field name="email" label="Email" type="email" autoComplete="email" />
113
125
  <Field name="phone" label="Phone" autoComplete="tel" />
114
126
  </fieldset>
115
127
 
116
- <fieldset>
117
- <legend>Shipping</legend>
118
- <Field name="recipientName" label="Recipient name" autoComplete="name" />
119
- <Field name="shippingPhone" label="Shipping phone" autoComplete="tel" />
120
- <Field name="postalCode" label="Postal code" autoComplete="postal-code" />
121
- <Field name="address" label="Address" autoComplete="street-address" />
122
- <Field name="detailAddress" label="Address detail" />
123
- <Field name="deliveryMessage" label="Shipping message" required={false} />
124
- </fieldset>
128
+ {hasShippableItems ? (
129
+ <fieldset>
130
+ <legend>Shipping</legend>
131
+ <Field
132
+ name="recipientName"
133
+ label="Recipient name"
134
+ autoComplete="name"
135
+ />
136
+ <Field
137
+ name="shippingPhone"
138
+ label="Shipping phone"
139
+ autoComplete="tel"
140
+ />
141
+ <Field
142
+ name="postalCode"
143
+ label="Postal code"
144
+ autoComplete="postal-code"
145
+ />
146
+ <Field
147
+ name="address"
148
+ label="Address"
149
+ autoComplete="street-address"
150
+ />
151
+ <Field name="detailAddress" label="Address detail" />
152
+ <Field
153
+ name="deliveryMessage"
154
+ label="Shipping message"
155
+ required={false}
156
+ />
157
+ </fieldset>
158
+ ) : null}
125
159
  </section>
126
160
 
127
161
  <aside>
128
162
  <h2>Payment handoff</h2>
129
163
  <p>
130
- Local demo payments complete immediately. Configured {providerLabel}{" "}
164
+ Local demo payments complete immediately. Configured {providerLabel}{' '}
131
165
  credentials start the provider handoff after the order is created.
132
166
  </p>
133
167
  <dl>
@@ -146,20 +180,27 @@ export function CheckoutForm() {
146
180
  </dl>
147
181
  {error ? <p role="alert">{error}</p> : null}
148
182
  <button type="submit" disabled={submitting}>
149
- {submitting ? "Creating order..." : "Start payment"}
183
+ {submitting ? 'Creating order...' : 'Start payment'}
150
184
  </button>
151
- <p aria-live="polite">{submitting ? "Creating order and payment request." : null}</p>
185
+ <p aria-live="polite">
186
+ {submitting ? 'Creating order and payment request.' : null}
187
+ </p>
152
188
  </aside>
153
189
  </form>
154
- );
190
+ )
155
191
  }
156
192
 
157
193
  // scaffold:provider:portone:start
158
- async function requestPortOnePayment(payment: Extract<ClientPaymentRequest, { provider: "portone" }>) {
159
- const PortOne = await import("@portone/browser-sdk/v2");
160
- const redirectUrl = new URL("/api/checkout/payment-return", window.location.origin);
161
- redirectUrl.searchParams.set("paymentId", payment.paymentId);
162
- redirectUrl.searchParams.set("orderNumber", payment.orderNumber);
194
+ async function requestPortOnePayment(
195
+ payment: Extract<ClientPaymentRequest, { provider: 'portone' }>,
196
+ ) {
197
+ const PortOne = await import('@portone/browser-sdk/v2')
198
+ const redirectUrl = new URL(
199
+ '/api/checkout/payment-return',
200
+ window.location.origin,
201
+ )
202
+ redirectUrl.searchParams.set('paymentId', payment.paymentId)
203
+ redirectUrl.searchParams.set('orderNumber', payment.orderNumber)
163
204
 
164
205
  const result = await PortOne.requestPayment({
165
206
  storeId: payment.storeId,
@@ -180,43 +221,42 @@ async function requestPortOnePayment(payment: Extract<ClientPaymentRequest, { pr
180
221
  // a webhook arriving before/instead of this redirect can resolve the open
181
222
  // checkout via a server re-fetch (#1544 / I6). No local index.
182
223
  customData: { orderNumber: payment.orderNumber },
183
- } as Parameters<typeof PortOne.requestPayment>[0]);
224
+ } as Parameters<typeof PortOne.requestPayment>[0])
184
225
 
185
226
  if (result?.code) {
186
- throw new Error(result.message ?? result.code);
227
+ throw new Error(result.message ?? result.code)
187
228
  }
188
229
 
189
230
  if (result?.paymentId) {
190
- window.location.assign(redirectUrl.toString());
231
+ window.location.assign(redirectUrl.toString())
191
232
  }
192
233
  }
193
234
  // scaffold:provider:portone:end
194
235
 
195
236
  // scaffold:provider:tosspayments:start
196
237
  async function requestTossPayments(
197
- payment: Extract<ClientPaymentRequest, { provider: "tosspayments" }>,
238
+ payment: Extract<ClientPaymentRequest, { provider: 'tosspayments' }>,
198
239
  ) {
199
- const { ANONYMOUS, loadTossPayments } = await import(
200
- "@tosspayments/tosspayments-sdk"
201
- );
202
- const tossPayments = await loadTossPayments(payment.clientKey);
240
+ const { ANONYMOUS, loadTossPayments } =
241
+ await import('@tosspayments/tosspayments-sdk')
242
+ const tossPayments = await loadTossPayments(payment.clientKey)
203
243
  const checkoutReturnUrl = new URL(
204
- "/api/checkout/payment-return",
244
+ '/api/checkout/payment-return',
205
245
  window.location.origin,
206
- );
207
- checkoutReturnUrl.searchParams.set("paymentId", payment.paymentId);
208
- checkoutReturnUrl.searchParams.set("orderNumber", payment.orderNumber);
246
+ )
247
+ checkoutReturnUrl.searchParams.set('paymentId', payment.paymentId)
248
+ checkoutReturnUrl.searchParams.set('orderNumber', payment.orderNumber)
209
249
 
210
- const failUrl = new URL("/checkout", window.location.origin);
211
- failUrl.searchParams.set("paymentId", payment.paymentId);
212
- failUrl.searchParams.set("orderNumber", payment.orderNumber);
250
+ const failUrl = new URL('/checkout', window.location.origin)
251
+ failUrl.searchParams.set('paymentId', payment.paymentId)
252
+ failUrl.searchParams.set('orderNumber', payment.orderNumber)
213
253
 
214
254
  const paymentClient = tossPayments.payment({
215
255
  customerKey: payment.customerKey ?? ANONYMOUS,
216
- });
256
+ })
217
257
 
218
258
  await paymentClient.requestPayment({
219
- method: "CARD",
259
+ method: 'CARD',
220
260
  amount: {
221
261
  currency: payment.currency,
222
262
  value: payment.amount,
@@ -232,22 +272,22 @@ async function requestTossPayments(
232
272
  // a webhook arriving before/instead of this redirect can resolve the open
233
273
  // checkout via a server re-fetch (#1544 / I6). No local index.
234
274
  metadata: { orderNumber: payment.orderNumber },
235
- });
275
+ })
236
276
  }
237
277
  // scaffold:provider:tosspayments:end
238
278
 
239
279
  function Field({
240
280
  label,
241
281
  name,
242
- type = "text",
282
+ type = 'text',
243
283
  autoComplete,
244
284
  required = true,
245
285
  }: {
246
- label: string;
247
- name: string;
248
- type?: string;
249
- autoComplete?: string;
250
- required?: boolean;
286
+ label: string
287
+ name: string
288
+ type?: string
289
+ autoComplete?: string
290
+ required?: boolean
251
291
  }) {
252
292
  return (
253
293
  <label>
@@ -259,9 +299,9 @@ function Field({
259
299
  required={required}
260
300
  />
261
301
  </label>
262
- );
302
+ )
263
303
  }
264
304
 
265
305
  function value(formData: FormData, key: string): string {
266
- return String(formData.get(key) ?? "").trim();
306
+ return String(formData.get(key) ?? '').trim()
267
307
  }
@@ -1,5 +1,10 @@
1
1
  import "server-only";
2
2
  import type { CartItemRef, CartView } from "../commerce/types.ts";
3
+ // scaffold:customer:start
4
+ import { resolveCustomerCartToken } from "../customer/cart-sync.ts";
5
+ import { createCustomerCommerceClient } from "../customer/client.server.ts";
6
+ import { getSessionToken } from "../customer/session.ts";
7
+ // scaffold:customer:end
3
8
  import { clearCartToken, readCartToken, writeCartToken } from "./cookie.ts";
4
9
  import { resolveCartProviderForRequest } from "./select-provider.ts";
5
10
 
@@ -40,16 +45,25 @@ export async function addItemToCart(item: CartItemRef): Promise<CartView> {
40
45
  const provider = await cartProvider();
41
46
  let token = await readCartToken();
42
47
 
48
+ // scaffold:customer:start
49
+ const sessionToken = await getSessionToken();
50
+ if (token && sessionToken) {
51
+ token = (await recoverCustomerCartToken(token, sessionToken)) ?? token;
52
+ }
53
+ // scaffold:customer:end
54
+
43
55
  if (token && !(await provider.getCart(token))) {
44
- token = null;
56
+ let nextToken: string | null = null;
57
+ // scaffold:customer:start
58
+ if (sessionToken) {
59
+ nextToken = await recoverCustomerCartToken(token, sessionToken);
60
+ }
61
+ // scaffold:customer:end
62
+ token = nextToken && (await provider.getCart(nextToken)) ? nextToken : null;
45
63
  }
46
64
  if (!token) {
47
65
  // Minted through the resolved provider: for a logged-in customer that is the
48
- // customer client, so create() is customer-bound. (If a prior login-sync
49
- // failed best-effort and left a guest token, the logged-in customer keeps
50
- // operating that same-identity guest cart until the next successful sync —
51
- // never another identity's cart, since the customer client re-verifies
52
- // ownership.)
66
+ // customer client, so create() is customer-bound.
53
67
  const created = await provider.createCart();
54
68
  token = created.cartToken;
55
69
  await writeCartToken(token);
@@ -94,3 +108,28 @@ async function requireCartToken(): Promise<string> {
94
108
  if (!token) throw new CartNotFoundError();
95
109
  return token;
96
110
  }
111
+
112
+ // scaffold:customer:start
113
+ async function recoverCustomerCartToken(
114
+ staleCartToken: string,
115
+ sessionToken: string,
116
+ ): Promise<string | null> {
117
+ const client = await createCustomerCommerceClient(sessionToken);
118
+ if (!client) return null;
119
+
120
+ try {
121
+ const { cartToken } = await resolveCustomerCartToken(
122
+ client.commerce,
123
+ staleCartToken,
124
+ );
125
+ if (cartToken) {
126
+ await writeCartToken(cartToken);
127
+ } else {
128
+ await clearCartToken();
129
+ }
130
+ return cartToken;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+ // scaffold:customer:end