@01.software/init 0.9.2 → 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.d.ts +13 -0
- package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
- package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
- package/dist/chunk-4LHYICUL.js.map +1 -0
- package/dist/{chunk-R4GGO33X.js → chunk-NJ4X7VNK.js} +1 -1
- package/dist/{chunk-R4GGO33X.js.map → chunk-NJ4X7VNK.js.map} +1 -1
- package/dist/chunk-STM4DKVZ.js +183 -0
- package/dist/chunk-STM4DKVZ.js.map +1 -0
- package/dist/{chunk-ENQSB4OF.js → chunk-WDWJ73KP.js} +40 -214
- package/dist/chunk-WDWJ73KP.js.map +1 -0
- package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
- package/dist/create-app-templates/ecommerce/CHANGELOG.md +48 -0
- package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
- package/dist/create-app-templates/ecommerce/README.md +154 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
- package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
- package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
- package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
- package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
- package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
- package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
- package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
- package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
- package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
- package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +129 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +307 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
- package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
- package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
- package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
- package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
- package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
- package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
- package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
- package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
- package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
- package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
- package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
- package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
- package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +135 -0
- package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
- package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +73 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +6 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +346 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +930 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +208 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
- package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
- package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
- package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
- package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
- package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
- package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
- package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
- package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
- package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
- package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
- package/dist/create-app-templates/ecommerce/package.json +33 -0
- package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
- package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
- package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +401 -0
- package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1632 -0
- package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
- package/dist/create-app-templates/registry.json +66 -0
- package/dist/create-app.d.ts +40 -0
- package/dist/create-app.js +652 -0
- package/dist/create-app.js.map +1 -0
- package/dist/detect-Bjxp9wcS.d.ts +13 -0
- package/dist/file-ops.d.ts +21 -0
- package/dist/file-ops.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +40 -0
- package/dist/init.js +4 -3
- package/dist/templates.d.ts +27 -0
- package/dist/templates.js +1 -1
- package/package.json +18 -3
- package/dist/chunk-ENQSB4OF.js.map +0 -1
- package/dist/chunk-UA7WNT2F.js.map +0 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Image from "next/image";
|
|
2
|
+
import type { ProductDetail } from "@/lib/commerce/types";
|
|
3
|
+
|
|
4
|
+
export function ProductGallery({ detail }: { detail: ProductDetail }) {
|
|
5
|
+
const images =
|
|
6
|
+
detail.product.images.length > 0
|
|
7
|
+
? detail.product.images
|
|
8
|
+
: [detail.product.thumbnail].filter((image) => image !== null && image !== undefined);
|
|
9
|
+
const primary = images[0];
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<figure>
|
|
13
|
+
{primary ? (
|
|
14
|
+
<Image
|
|
15
|
+
src={primary.url}
|
|
16
|
+
alt={primary.alt ?? detail.product.title}
|
|
17
|
+
width={1000}
|
|
18
|
+
height={800}
|
|
19
|
+
priority
|
|
20
|
+
/>
|
|
21
|
+
) : null}
|
|
22
|
+
<figcaption>{detail.product.title}</figcaption>
|
|
23
|
+
{images.length > 1 ? (
|
|
24
|
+
<ul aria-label="Additional product images">
|
|
25
|
+
{images.slice(1, 4).map((image) => (
|
|
26
|
+
<li key={image.url}>
|
|
27
|
+
<Image
|
|
28
|
+
src={image.url}
|
|
29
|
+
alt={image.alt ?? detail.product.title}
|
|
30
|
+
width={320}
|
|
31
|
+
height={320}
|
|
32
|
+
/>
|
|
33
|
+
</li>
|
|
34
|
+
))}
|
|
35
|
+
</ul>
|
|
36
|
+
) : null}
|
|
37
|
+
</figure>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -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,135 @@
|
|
|
1
|
+
import "server-only";
|
|
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
|
|
8
|
+
import { clearCartToken, readCartToken, writeCartToken } from "./cookie.ts";
|
|
9
|
+
import { resolveCartProviderForRequest } from "./select-provider.ts";
|
|
10
|
+
|
|
11
|
+
/** Thrown when a mutation is attempted without an owned cart cookie. */
|
|
12
|
+
export class CartNotFoundError extends Error {
|
|
13
|
+
constructor() {
|
|
14
|
+
super("No active cart");
|
|
15
|
+
this.name = "CartNotFoundError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the cart provider for the current request. Guest by default; a
|
|
21
|
+
* logged-in customer's session cookie (when customer accounts are present)
|
|
22
|
+
* routes to the customer-JWT-scoped provider — see `select-provider.ts`.
|
|
23
|
+
*/
|
|
24
|
+
const cartProvider = resolveCartProviderForRequest;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read the current cart from the ownership cookie. Read-only: a
|
|
28
|
+
* stale/expired/completed token resolves to `null` (a fresh cart is minted on
|
|
29
|
+
* the next add) and the cookie is not mutated here, so this is safe to call
|
|
30
|
+
* from any context including Server Component render.
|
|
31
|
+
*/
|
|
32
|
+
export async function getCartFromCookie(): Promise<CartView | null> {
|
|
33
|
+
const token = await readCartToken();
|
|
34
|
+
if (!token) return null;
|
|
35
|
+
return (await cartProvider()).getCart(token);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Add a line, creating + stamping a fresh cart cookie when none is owned yet (or
|
|
40
|
+
* when the owned token no longer resolves to an active cart). For a logged-in
|
|
41
|
+
* customer the new cart is minted via the customer-JWT client, so it is
|
|
42
|
+
* customer-bound (JWT auto-binds `customer`) — never a guest cart.
|
|
43
|
+
*/
|
|
44
|
+
export async function addItemToCart(item: CartItemRef): Promise<CartView> {
|
|
45
|
+
const provider = await cartProvider();
|
|
46
|
+
let token = await readCartToken();
|
|
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
|
+
|
|
55
|
+
if (token && !(await provider.getCart(token))) {
|
|
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;
|
|
63
|
+
}
|
|
64
|
+
if (!token) {
|
|
65
|
+
// Minted through the resolved provider: for a logged-in customer that is the
|
|
66
|
+
// customer client, so create() is customer-bound.
|
|
67
|
+
const created = await provider.createCart();
|
|
68
|
+
token = created.cartToken;
|
|
69
|
+
await writeCartToken(token);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return provider.addCartItem({ cartToken: token, item });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function updateCartItemQuantity(input: {
|
|
76
|
+
cartItemId: string;
|
|
77
|
+
quantity: number;
|
|
78
|
+
}): Promise<CartView> {
|
|
79
|
+
const token = await requireCartToken();
|
|
80
|
+
return (await cartProvider()).updateCartItem({ cartToken: token, ...input });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function removeCartItemFromCart(input: {
|
|
84
|
+
cartItemId: string;
|
|
85
|
+
}): Promise<CartView> {
|
|
86
|
+
const token = await requireCartToken();
|
|
87
|
+
return (await cartProvider()).removeCartItem({ cartToken: token, ...input });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function clearActiveCart(): Promise<CartView | null> {
|
|
91
|
+
const token = await readCartToken();
|
|
92
|
+
if (!token) return null;
|
|
93
|
+
return (await cartProvider()).clearCart({ cartToken: token });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Returns the owned cart token for checkout, or null when no cart is owned. */
|
|
97
|
+
export async function getCheckoutCartToken(): Promise<string | null> {
|
|
98
|
+
return readCartToken();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Drop the cart cookie once a checkout has consumed the cart. */
|
|
102
|
+
export async function dropCartCookie(): Promise<void> {
|
|
103
|
+
await clearCartToken();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function requireCartToken(): Promise<string> {
|
|
107
|
+
const token = await readCartToken();
|
|
108
|
+
if (!token) throw new CartNotFoundError();
|
|
109
|
+
return token;
|
|
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
|
|
@@ -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
|
+
}
|