@01.software/init 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/dist/ai-docs.d.ts +13 -0
  2. package/dist/ai-docs.js +1 -1
  3. package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
  4. package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
  5. package/dist/chunk-4LHYICUL.js.map +1 -0
  6. package/dist/{chunk-TBGKXE3Q.js → chunk-NJ4X7VNK.js} +5 -5
  7. package/dist/chunk-NJ4X7VNK.js.map +1 -0
  8. package/dist/{chunk-5K2CB2Y5.js → chunk-Q6MSORYN.js} +14 -37
  9. package/dist/chunk-Q6MSORYN.js.map +1 -0
  10. package/dist/chunk-STM4DKVZ.js +183 -0
  11. package/dist/chunk-STM4DKVZ.js.map +1 -0
  12. package/dist/{chunk-2IGKOSK7.js → chunk-WDWJ73KP.js} +41 -215
  13. package/dist/chunk-WDWJ73KP.js.map +1 -0
  14. package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
  15. package/dist/create-app-templates/ecommerce/CHANGELOG.md +30 -0
  16. package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
  17. package/dist/create-app-templates/ecommerce/README.md +139 -0
  18. package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
  19. package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
  20. package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
  21. package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
  22. package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
  23. package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
  24. package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
  25. package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
  26. package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
  27. package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
  28. package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
  29. package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
  30. package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
  31. package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
  32. package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
  33. package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
  34. package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
  35. package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
  36. package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
  37. package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
  38. package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
  39. package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
  40. package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
  41. package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +119 -0
  42. package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +267 -0
  43. package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
  44. package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
  45. package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
  46. package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
  47. package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
  48. package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
  49. package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
  50. package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
  51. package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
  52. package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
  53. package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
  54. package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
  55. package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
  56. package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
  57. package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
  58. package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
  59. package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +96 -0
  60. package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
  61. package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
  62. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
  63. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
  64. package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +76 -0
  65. package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +63 -0
  66. package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +3 -0
  67. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +336 -0
  68. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
  69. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +913 -0
  70. package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
  71. package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
  72. package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
  73. package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
  74. package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +206 -0
  75. package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
  76. package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
  77. package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
  78. package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
  79. package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
  80. package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
  81. package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
  82. package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
  83. package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
  84. package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
  85. package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
  86. package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
  87. package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
  88. package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
  89. package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
  90. package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
  91. package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
  92. package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
  93. package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
  94. package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
  95. package/dist/create-app-templates/ecommerce/package.json +33 -0
  96. package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
  97. package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
  98. package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +392 -0
  99. package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1537 -0
  100. package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
  101. package/dist/create-app-templates/registry.json +66 -0
  102. package/dist/create-app.d.ts +40 -0
  103. package/dist/create-app.js +652 -0
  104. package/dist/create-app.js.map +1 -0
  105. package/dist/detect-Bjxp9wcS.d.ts +13 -0
  106. package/dist/file-ops.d.ts +21 -0
  107. package/dist/file-ops.js +1 -1
  108. package/dist/index.d.ts +2 -0
  109. package/dist/index.js +6 -5
  110. package/dist/index.js.map +1 -1
  111. package/dist/init.d.ts +40 -0
  112. package/dist/init.js +5 -4
  113. package/dist/templates.d.ts +27 -0
  114. package/dist/templates.js +1 -1
  115. package/package.json +31 -15
  116. package/dist/chunk-2IGKOSK7.js.map +0 -1
  117. package/dist/chunk-5K2CB2Y5.js.map +0 -1
  118. package/dist/chunk-TBGKXE3Q.js.map +0 -1
  119. package/dist/chunk-UA7WNT2F.js.map +0 -1
@@ -0,0 +1,173 @@
1
+ {
2
+ "shippingPolicy": {
3
+ "currency": "KRW",
4
+ "baseAmount": 3000,
5
+ "freeAboveAmount": 100000
6
+ },
7
+ "products": [
8
+ {
9
+ "product": {
10
+ "id": "prod-canvas-tote",
11
+ "slug": "canvas-market-tote",
12
+ "title": "Canvas Market Tote",
13
+ "description": "A sturdy everyday tote with enough structure for books, produce, and a laptop sleeve.",
14
+ "thumbnail": {
15
+ "url": "https://images.unsplash.com/photo-1594223274512-ad4803739b7c?auto=format&fit=crop&w=900&q=80",
16
+ "alt": "Canvas tote bag on a neutral studio surface"
17
+ },
18
+ "images": [
19
+ {
20
+ "url": "https://images.unsplash.com/photo-1594223274512-ad4803739b7c?auto=format&fit=crop&w=1200&q=80",
21
+ "alt": "Canvas tote bag on a neutral studio surface"
22
+ },
23
+ {
24
+ "url": "https://images.unsplash.com/photo-1542291026-7eec264c27ff?auto=format&fit=crop&w=1200&q=80",
25
+ "alt": "Packed goods for a daily market run"
26
+ }
27
+ ],
28
+ "status": "published"
29
+ },
30
+ "options": [
31
+ {
32
+ "id": "opt-color",
33
+ "slug": "color",
34
+ "title": "Color",
35
+ "values": [
36
+ { "id": "val-natural", "slug": "natural", "value": "Natural" },
37
+ { "id": "val-ink", "slug": "ink", "value": "Ink" }
38
+ ]
39
+ }
40
+ ],
41
+ "variants": [
42
+ {
43
+ "id": "var-tote-natural",
44
+ "productId": "prod-canvas-tote",
45
+ "title": "Natural",
46
+ "sku": "TOTE-NAT",
47
+ "price": 42000,
48
+ "stock": 18,
49
+ "reservedStock": 3,
50
+ "isUnlimited": false,
51
+ "optionValues": [
52
+ {
53
+ "optionId": "opt-color",
54
+ "valueId": "val-natural",
55
+ "valueSlug": "natural",
56
+ "value": "Natural"
57
+ }
58
+ ],
59
+ "images": [
60
+ {
61
+ "url": "https://images.unsplash.com/photo-1594223274512-ad4803739b7c?auto=format&fit=crop&w=1200&q=80",
62
+ "alt": "Natural canvas tote"
63
+ }
64
+ ]
65
+ },
66
+ {
67
+ "id": "var-tote-ink",
68
+ "productId": "prod-canvas-tote",
69
+ "title": "Ink",
70
+ "sku": "TOTE-INK",
71
+ "price": 45000,
72
+ "stock": 0,
73
+ "reservedStock": 0,
74
+ "isUnlimited": false,
75
+ "optionValues": [
76
+ {
77
+ "optionId": "opt-color",
78
+ "valueId": "val-ink",
79
+ "valueSlug": "ink",
80
+ "value": "Ink"
81
+ }
82
+ ],
83
+ "images": [
84
+ {
85
+ "url": "https://images.unsplash.com/photo-1590874103328-eac38a683ce7?auto=format&fit=crop&w=1200&q=80",
86
+ "alt": "Dark tote bag"
87
+ }
88
+ ]
89
+ }
90
+ ]
91
+ },
92
+ {
93
+ "product": {
94
+ "id": "prod-desk-lamp",
95
+ "slug": "task-lamp",
96
+ "title": "Task Lamp",
97
+ "description": "A compact aluminum lamp with a warm dimmable beam for late packing, reading, or desk work.",
98
+ "thumbnail": {
99
+ "url": "https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&fit=crop&w=900&q=80",
100
+ "alt": "Black task lamp on a desk"
101
+ },
102
+ "images": [
103
+ {
104
+ "url": "https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&fit=crop&w=1200&q=80",
105
+ "alt": "Black task lamp on a desk"
106
+ }
107
+ ],
108
+ "status": "published"
109
+ },
110
+ "options": [
111
+ {
112
+ "id": "opt-finish",
113
+ "slug": "finish",
114
+ "title": "Finish",
115
+ "values": [
116
+ { "id": "val-black", "slug": "black", "value": "Black" },
117
+ { "id": "val-silver", "slug": "silver", "value": "Silver" }
118
+ ]
119
+ }
120
+ ],
121
+ "variants": [
122
+ {
123
+ "id": "var-lamp-black",
124
+ "productId": "prod-desk-lamp",
125
+ "title": "Black",
126
+ "sku": "LAMP-BLK",
127
+ "price": 78000,
128
+ "stock": 6,
129
+ "reservedStock": 1,
130
+ "isUnlimited": false,
131
+ "optionValues": [
132
+ {
133
+ "optionId": "opt-finish",
134
+ "valueId": "val-black",
135
+ "valueSlug": "black",
136
+ "value": "Black"
137
+ }
138
+ ],
139
+ "images": [
140
+ {
141
+ "url": "https://images.unsplash.com/photo-1507473885765-e6ed057f782c?auto=format&fit=crop&w=1200&q=80",
142
+ "alt": "Black task lamp"
143
+ }
144
+ ]
145
+ },
146
+ {
147
+ "id": "var-lamp-silver",
148
+ "productId": "prod-desk-lamp",
149
+ "title": "Silver",
150
+ "sku": "LAMP-SLV",
151
+ "price": 82000,
152
+ "stock": 1000,
153
+ "reservedStock": 0,
154
+ "isUnlimited": true,
155
+ "optionValues": [
156
+ {
157
+ "optionId": "opt-finish",
158
+ "valueId": "val-silver",
159
+ "valueSlug": "silver",
160
+ "value": "Silver"
161
+ }
162
+ ],
163
+ "images": [
164
+ {
165
+ "url": "https://images.unsplash.com/photo-1513506003901-1e6a229e2d15?auto=format&fit=crop&w=1200&q=80",
166
+ "alt": "Silver desk lamp"
167
+ }
168
+ ]
169
+ }
170
+ ]
171
+ }
172
+ ]
173
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,40 @@
1
+ import "server-only";
2
+ import { cookies } from "next/headers";
3
+ import { appConfig } from "@/app-config";
4
+
5
+ /**
6
+ * Guest-cart ownership cookie. It holds the unguessable `cartToken` capability
7
+ * — never the enumerable cart id — and is **HttpOnly + SameSite + Secure** so it
8
+ * is invisible to client JS (XSS) and only travels on same-site requests. The
9
+ * SDK cart endpoints re-verify this token against the cart row on every
10
+ * mutation (`assertCartAccess`), so possession of the cookie is the guest
11
+ * ownership check.
12
+ *
13
+ * `.set`/`.delete` are only valid in Route Handlers / Server Functions, never
14
+ * during Server Component render — keep cookie writes in the cart/checkout
15
+ * routes.
16
+ */
17
+ const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days
18
+
19
+ export async function readCartToken(): Promise<string | null> {
20
+ const store = await cookies();
21
+ return store.get(appConfig.cartKey)?.value ?? null;
22
+ }
23
+
24
+ export async function writeCartToken(token: string): Promise<void> {
25
+ const store = await cookies();
26
+ store.set(appConfig.cartKey, token, {
27
+ httpOnly: true,
28
+ // Secure cookies are dropped by browsers over plain http, so allow http in
29
+ // local dev and enforce Secure in production.
30
+ secure: process.env.NODE_ENV === "production",
31
+ sameSite: "lax",
32
+ path: "/",
33
+ maxAge: COOKIE_MAX_AGE_SECONDS,
34
+ });
35
+ }
36
+
37
+ export async function clearCartToken(): Promise<void> {
38
+ const store = await cookies();
39
+ store.delete(appConfig.cartKey);
40
+ }
@@ -0,0 +1,32 @@
1
+ import type { CartLine } from "../commerce/types.ts";
2
+
3
+ const MAX_LINE_QUANTITY = 99;
4
+
5
+ export function normalizeCartLines(input: unknown): CartLine[] {
6
+ if (!Array.isArray(input)) return [];
7
+
8
+ const quantities = new Map<string, number>();
9
+
10
+ for (const line of input) {
11
+ if (!line || typeof line !== "object") continue;
12
+
13
+ const candidate = line as { variantId?: unknown; quantity?: unknown };
14
+ if (typeof candidate.variantId !== "string") continue;
15
+
16
+ const variantId = candidate.variantId.trim();
17
+ if (!variantId) continue;
18
+
19
+ const quantity =
20
+ typeof candidate.quantity === "number" && Number.isFinite(candidate.quantity)
21
+ ? Math.floor(candidate.quantity)
22
+ : 1;
23
+ const safeQuantity = Math.min(MAX_LINE_QUANTITY, Math.max(1, quantity));
24
+
25
+ quantities.set(variantId, (quantities.get(variantId) ?? 0) + safeQuantity);
26
+ }
27
+
28
+ return Array.from(quantities.entries()).map(([variantId, quantity]) => ({
29
+ variantId,
30
+ quantity: Math.min(MAX_LINE_QUANTITY, quantity),
31
+ }));
32
+ }
@@ -0,0 +1,56 @@
1
+ import type { CartItemRef } from "../commerce/types.ts";
2
+
3
+ const MAX_QUANTITY = 99;
4
+
5
+ export function parseAddItem(input: unknown): CartItemRef {
6
+ const body = asRecord(input);
7
+ const option = optionalString(body.optionId);
8
+ return {
9
+ productId: requiredString(body.productId, "productId"),
10
+ variantId: requiredString(body.variantId, "variantId"),
11
+ ...(option ? { option } : {}),
12
+ quantity: parseQuantity(body.quantity),
13
+ };
14
+ }
15
+
16
+ export function parseUpdateItem(input: unknown): {
17
+ cartItemId: string;
18
+ quantity: number;
19
+ } {
20
+ const body = asRecord(input);
21
+ return {
22
+ cartItemId: requiredString(body.cartItemId, "cartItemId"),
23
+ quantity: parseQuantity(body.quantity),
24
+ };
25
+ }
26
+
27
+ export function parseRemoveItem(input: unknown): { cartItemId: string } {
28
+ const body = asRecord(input);
29
+ return { cartItemId: requiredString(body.cartItemId, "cartItemId") };
30
+ }
31
+
32
+ function asRecord(input: unknown): Record<string, unknown> {
33
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
34
+ throw new Error("Request body must be an object");
35
+ }
36
+ return input as Record<string, unknown>;
37
+ }
38
+
39
+ function requiredString(input: unknown, field: string): string {
40
+ if (typeof input !== "string" || input.trim() === "") {
41
+ throw new Error(`${field} is required`);
42
+ }
43
+ return input.trim();
44
+ }
45
+
46
+ function optionalString(input: unknown): string | undefined {
47
+ if (input === undefined || input === null || input === "") return undefined;
48
+ if (typeof input !== "string") throw new Error("optionId must be a string");
49
+ return input.trim() || undefined;
50
+ }
51
+
52
+ function parseQuantity(input: unknown): number {
53
+ const value =
54
+ typeof input === "number" && Number.isFinite(input) ? Math.floor(input) : 1;
55
+ return Math.min(MAX_QUANTITY, Math.max(1, value));
56
+ }
@@ -0,0 +1,17 @@
1
+ import { CartNotFoundError } from "./server-cart.ts";
2
+
3
+ /** Map a cart-route error to a public JSON response with a sensible status. */
4
+ export function cartErrorResponse(error: unknown): Response {
5
+ const message = error instanceof Error ? error.message : "Cart request failed";
6
+
7
+ if (error instanceof CartNotFoundError) {
8
+ return Response.json({ code: "cart_not_found", message }, { status: 409 });
9
+ }
10
+ if (/required|must be an object|must be a string/.test(message)) {
11
+ return Response.json({ code: "invalid_request", message }, { status: 400 });
12
+ }
13
+ if (/stock|not found/i.test(message)) {
14
+ return Response.json({ code: "cart_unavailable", message }, { status: 422 });
15
+ }
16
+ return Response.json({ code: "cart_failed", message }, { status: 500 });
17
+ }
@@ -0,0 +1,44 @@
1
+ import "../server-only-guard.ts";
2
+ import {
3
+ getCommerceProvider,
4
+ getCustomerCartProvider,
5
+ } from "../commerce/provider.server.ts";
6
+ import type { CartProvider } from "../commerce/provider.ts";
7
+ // scaffold:customer:start
8
+ import { getSessionToken } from "../customer/session.ts";
9
+ // scaffold:customer:end
10
+
11
+ /**
12
+ * Pick the cart provider for a session token. A logged-in customer (token
13
+ * present) operates a server-owned, device-portable, customer-bound cart through
14
+ * a **customer-JWT-scoped** client; an anonymous visitor keeps the guest cart
15
+ * path. Both still address the cart by the `cartToken` in the HttpOnly cart
16
+ * cookie — only the client (and thus the ownership check) differs.
17
+ *
18
+ * Dependency-injected so the branch unit-tests with fake providers and without
19
+ * `next/headers`.
20
+ */
21
+ export function resolveCartProvider(
22
+ sessionToken: string | null,
23
+ deps: {
24
+ guest: () => CartProvider;
25
+ customer: (token: string) => CartProvider;
26
+ } = { guest: getCommerceProvider, customer: getCustomerCartProvider },
27
+ ): CartProvider {
28
+ return sessionToken ? deps.customer(sessionToken) : deps.guest();
29
+ }
30
+
31
+ /**
32
+ * Resolve the cart provider for the current request, reading the customer-JWT
33
+ * session cookie. The customer-session coupling lives in the
34
+ * `scaffold:customer` block so a guest-only scaffold (customer accounts pruned)
35
+ * compiles to the guest path alone — without it, `sessionToken` stays `null` and
36
+ * this always returns the guest provider.
37
+ */
38
+ export async function resolveCartProviderForRequest(): Promise<CartProvider> {
39
+ // scaffold:customer:start
40
+ const sessionToken = await getSessionToken();
41
+ if (sessionToken) return resolveCartProvider(sessionToken);
42
+ // scaffold:customer:end
43
+ return resolveCartProvider(null);
44
+ }
@@ -0,0 +1,96 @@
1
+ import "server-only";
2
+ import type { CartItemRef, CartView } from "../commerce/types.ts";
3
+ import { clearCartToken, readCartToken, writeCartToken } from "./cookie.ts";
4
+ import { resolveCartProviderForRequest } from "./select-provider.ts";
5
+
6
+ /** Thrown when a mutation is attempted without an owned cart cookie. */
7
+ export class CartNotFoundError extends Error {
8
+ constructor() {
9
+ super("No active cart");
10
+ this.name = "CartNotFoundError";
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Resolve the cart provider for the current request. Guest by default; a
16
+ * logged-in customer's session cookie (when customer accounts are present)
17
+ * routes to the customer-JWT-scoped provider — see `select-provider.ts`.
18
+ */
19
+ const cartProvider = resolveCartProviderForRequest;
20
+
21
+ /**
22
+ * Read the current cart from the ownership cookie. Read-only: a
23
+ * stale/expired/completed token resolves to `null` (a fresh cart is minted on
24
+ * the next add) and the cookie is not mutated here, so this is safe to call
25
+ * from any context including Server Component render.
26
+ */
27
+ export async function getCartFromCookie(): Promise<CartView | null> {
28
+ const token = await readCartToken();
29
+ if (!token) return null;
30
+ return (await cartProvider()).getCart(token);
31
+ }
32
+
33
+ /**
34
+ * Add a line, creating + stamping a fresh cart cookie when none is owned yet (or
35
+ * when the owned token no longer resolves to an active cart). For a logged-in
36
+ * customer the new cart is minted via the customer-JWT client, so it is
37
+ * customer-bound (JWT auto-binds `customer`) — never a guest cart.
38
+ */
39
+ export async function addItemToCart(item: CartItemRef): Promise<CartView> {
40
+ const provider = await cartProvider();
41
+ let token = await readCartToken();
42
+
43
+ if (token && !(await provider.getCart(token))) {
44
+ token = null;
45
+ }
46
+ if (!token) {
47
+ // 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.)
53
+ const created = await provider.createCart();
54
+ token = created.cartToken;
55
+ await writeCartToken(token);
56
+ }
57
+
58
+ return provider.addCartItem({ cartToken: token, item });
59
+ }
60
+
61
+ export async function updateCartItemQuantity(input: {
62
+ cartItemId: string;
63
+ quantity: number;
64
+ }): Promise<CartView> {
65
+ const token = await requireCartToken();
66
+ return (await cartProvider()).updateCartItem({ cartToken: token, ...input });
67
+ }
68
+
69
+ export async function removeCartItemFromCart(input: {
70
+ cartItemId: string;
71
+ }): Promise<CartView> {
72
+ const token = await requireCartToken();
73
+ return (await cartProvider()).removeCartItem({ cartToken: token, ...input });
74
+ }
75
+
76
+ export async function clearActiveCart(): Promise<CartView | null> {
77
+ const token = await readCartToken();
78
+ if (!token) return null;
79
+ return (await cartProvider()).clearCart({ cartToken: token });
80
+ }
81
+
82
+ /** Returns the owned cart token for checkout, or null when no cart is owned. */
83
+ export async function getCheckoutCartToken(): Promise<string | null> {
84
+ return readCartToken();
85
+ }
86
+
87
+ /** Drop the cart cookie once a checkout has consumed the cart. */
88
+ export async function dropCartCookie(): Promise<void> {
89
+ await clearCartToken();
90
+ }
91
+
92
+ async function requireCartToken(): Promise<string> {
93
+ const token = await readCartToken();
94
+ if (!token) throw new CartNotFoundError();
95
+ return token;
96
+ }
@@ -0,0 +1,34 @@
1
+ import "server-only";
2
+ import { resolveCustomerCartToken } from "../customer/cart-sync.ts";
3
+ import { createCustomerCommerceClient } from "../customer/client.server.ts";
4
+ import { clearCartToken, readCartToken, writeCartToken } from "./cookie.ts";
5
+
6
+ /**
7
+ * After a login/register mints the session JWT, slave the active-cart cookie to
8
+ * the customer's server-owned cart: union any pre-login guest cart, else load
9
+ * the existing customer cart, then persist the resolved `cartToken` (or clear
10
+ * the cookie when the customer has no active cart). Route Handlers only — it
11
+ * writes cookies.
12
+ *
13
+ * Best-effort: a sync failure (network/backend) must not fail the login. The
14
+ * session JWT cookie is already set; the next cart action recovers (a logged-in
15
+ * `addItem` mints a customer-bound cart through the customer client).
16
+ */
17
+ export async function syncActiveCartCookieOnLogin(token: string): Promise<void> {
18
+ const client = await createCustomerCommerceClient(token);
19
+ if (!client) return;
20
+ const guestCartToken = await readCartToken();
21
+ try {
22
+ const { cartToken } = await resolveCustomerCartToken(
23
+ client.commerce,
24
+ guestCartToken,
25
+ );
26
+ if (cartToken) {
27
+ await writeCartToken(cartToken);
28
+ } else {
29
+ await clearCartToken();
30
+ }
31
+ } catch {
32
+ // Leave the cart cookie as-is; login itself must still succeed.
33
+ }
34
+ }
@@ -0,0 +1,151 @@
1
+ "use client";
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useState,
10
+ type ReactNode,
11
+ } from "react";
12
+ import type { CartItemRef, CartView } from "../commerce/types.ts";
13
+
14
+ type CartContextValue = {
15
+ cart: CartView | null;
16
+ loading: boolean;
17
+ error: string | null;
18
+ itemCount: number;
19
+ addItem: (item: CartItemRef) => Promise<boolean>;
20
+ updateItem: (input: {
21
+ cartItemId: string;
22
+ quantity: number;
23
+ }) => Promise<void>;
24
+ removeItem: (cartItemId: string) => Promise<void>;
25
+ clear: () => Promise<void>;
26
+ /** Drop the local cart view (e.g. after checkout consumed the server cart). */
27
+ reset: () => void;
28
+ };
29
+
30
+ const CartContext = createContext<CartContextValue | null>(null);
31
+
32
+ /**
33
+ * Client cart state backed by the server cart routes (`/api/cart*`). The cart
34
+ * lives in the Console (`commerce.cart.*`); this holds only the rendered view —
35
+ * the ownership `cartToken` stays in the HttpOnly cookie, never in JS.
36
+ */
37
+ export function CartProvider({ children }: { children: ReactNode }) {
38
+ const [cart, setCart] = useState<CartView | null>(null);
39
+ const [loading, setLoading] = useState(true);
40
+ const [error, setError] = useState<string | null>(null);
41
+
42
+ // Hydrate the current cart on mount. setState lives in the async resolution
43
+ // callbacks (external-system → React sync), not the synchronous effect body.
44
+ useEffect(() => {
45
+ let active = true;
46
+ fetch("/api/cart")
47
+ .then((response) => response.json() as Promise<{ cart?: CartView | null }>)
48
+ .then((data) => {
49
+ if (!active) return;
50
+ setCart(data.cart ?? null);
51
+ setLoading(false);
52
+ })
53
+ .catch(() => {
54
+ if (!active) return;
55
+ setCart(null);
56
+ setLoading(false);
57
+ });
58
+ return () => {
59
+ active = false;
60
+ };
61
+ }, []);
62
+
63
+ const mutate = useCallback(
64
+ async (url: string, method: string, body?: unknown): Promise<boolean> => {
65
+ setError(null);
66
+ try {
67
+ const response = await fetch(url, {
68
+ method,
69
+ headers: { "Content-Type": "application/json" },
70
+ body: body === undefined ? undefined : JSON.stringify(body),
71
+ });
72
+ const data = (await response.json().catch(() => ({}))) as {
73
+ cart?: CartView | null;
74
+ message?: string;
75
+ };
76
+ if (!response.ok) {
77
+ setError(data.message ?? "Cart update failed");
78
+ return false;
79
+ }
80
+ setCart(data.cart ?? null);
81
+ return true;
82
+ } catch {
83
+ setError("Cart update failed");
84
+ return false;
85
+ }
86
+ },
87
+ [],
88
+ );
89
+
90
+ const addItem = useCallback(
91
+ (item: CartItemRef) => mutate("/api/cart/items", "POST", item),
92
+ [mutate],
93
+ );
94
+ const updateItem = useCallback(
95
+ async (input: { cartItemId: string; quantity: number }) => {
96
+ await mutate("/api/cart/items", "PATCH", input);
97
+ },
98
+ [mutate],
99
+ );
100
+ const removeItem = useCallback(
101
+ async (cartItemId: string) => {
102
+ await mutate("/api/cart/items", "DELETE", { cartItemId });
103
+ },
104
+ [mutate],
105
+ );
106
+ const clear = useCallback(async () => {
107
+ await mutate("/api/cart/clear", "POST");
108
+ }, [mutate]);
109
+ const reset = useCallback(() => {
110
+ setCart(null);
111
+ setError(null);
112
+ }, []);
113
+
114
+ const itemCount =
115
+ cart?.items.reduce((sum, item) => sum + item.quantity, 0) ?? 0;
116
+
117
+ const value = useMemo<CartContextValue>(
118
+ () => ({
119
+ cart,
120
+ loading,
121
+ error,
122
+ itemCount,
123
+ addItem,
124
+ updateItem,
125
+ removeItem,
126
+ clear,
127
+ reset,
128
+ }),
129
+ [
130
+ cart,
131
+ loading,
132
+ error,
133
+ itemCount,
134
+ addItem,
135
+ updateItem,
136
+ removeItem,
137
+ clear,
138
+ reset,
139
+ ],
140
+ );
141
+
142
+ return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
143
+ }
144
+
145
+ export function useCart(): CartContextValue {
146
+ const context = useContext(CartContext);
147
+ if (!context) {
148
+ throw new Error("useCart must be used within a CartProvider");
149
+ }
150
+ return context;
151
+ }
@@ -0,0 +1,22 @@
1
+ const CHECKOUT_VALIDATION_PATTERN = /required|must be|valid email|payload|empty/;
2
+
3
+ export function getCheckoutErrorMessage(error: unknown): string {
4
+ return error instanceof Error ? error.message : "Checkout failed";
5
+ }
6
+
7
+ export function getCheckoutErrorStatus(error: unknown): number {
8
+ const status = readHttpStatus(error);
9
+ if (status !== null) return status;
10
+
11
+ return CHECKOUT_VALIDATION_PATTERN.test(getCheckoutErrorMessage(error))
12
+ ? 400
13
+ : 422;
14
+ }
15
+
16
+ function readHttpStatus(error: unknown): number | null {
17
+ if (typeof error !== "object" || error === null) return null;
18
+ const status = (error as { status?: unknown }).status;
19
+ return typeof status === "number" && status >= 400 && status < 500
20
+ ? status
21
+ : null;
22
+ }