@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.
Files changed (114) hide show
  1. package/dist/ai-docs.d.ts +13 -0
  2. package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
  3. package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
  4. package/dist/chunk-4LHYICUL.js.map +1 -0
  5. package/dist/{chunk-R4GGO33X.js → chunk-NJ4X7VNK.js} +1 -1
  6. package/dist/{chunk-R4GGO33X.js.map → chunk-NJ4X7VNK.js.map} +1 -1
  7. package/dist/chunk-STM4DKVZ.js +183 -0
  8. package/dist/chunk-STM4DKVZ.js.map +1 -0
  9. package/dist/{chunk-ENQSB4OF.js → chunk-WDWJ73KP.js} +40 -214
  10. package/dist/chunk-WDWJ73KP.js.map +1 -0
  11. package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
  12. package/dist/create-app-templates/ecommerce/CHANGELOG.md +48 -0
  13. package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
  14. package/dist/create-app-templates/ecommerce/README.md +154 -0
  15. package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
  16. package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
  17. package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
  18. package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
  19. package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
  20. package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
  21. package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
  22. package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
  23. package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
  24. package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
  25. package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
  26. package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
  27. package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
  28. package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
  29. package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
  30. package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
  31. package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
  32. package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
  33. package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
  34. package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
  35. package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
  36. package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
  37. package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
  38. package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +129 -0
  39. package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +307 -0
  40. package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
  41. package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
  42. package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
  43. package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
  44. package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
  45. package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
  46. package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
  47. package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
  48. package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
  49. package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
  50. package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
  51. package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
  52. package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
  53. package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
  54. package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
  55. package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
  56. package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +135 -0
  57. package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
  58. package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
  59. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
  60. package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
  61. package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +86 -0
  62. package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +73 -0
  63. package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +6 -0
  64. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +346 -0
  65. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
  66. package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +930 -0
  67. package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
  68. package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
  69. package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
  70. package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
  71. package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +208 -0
  72. package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
  73. package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
  74. package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
  75. package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
  76. package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
  77. package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
  78. package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
  79. package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
  80. package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
  81. package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
  82. package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
  83. package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
  84. package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
  85. package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
  86. package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
  87. package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
  88. package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
  89. package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
  90. package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
  91. package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
  92. package/dist/create-app-templates/ecommerce/package.json +33 -0
  93. package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
  94. package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
  95. package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +401 -0
  96. package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1632 -0
  97. package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
  98. package/dist/create-app-templates/registry.json +66 -0
  99. package/dist/create-app.d.ts +40 -0
  100. package/dist/create-app.js +652 -0
  101. package/dist/create-app.js.map +1 -0
  102. package/dist/detect-Bjxp9wcS.d.ts +13 -0
  103. package/dist/file-ops.d.ts +21 -0
  104. package/dist/file-ops.js +1 -1
  105. package/dist/index.d.ts +2 -0
  106. package/dist/index.js +4 -3
  107. package/dist/index.js.map +1 -1
  108. package/dist/init.d.ts +40 -0
  109. package/dist/init.js +4 -3
  110. package/dist/templates.d.ts +27 -0
  111. package/dist/templates.js +1 -1
  112. package/package.json +18 -3
  113. package/dist/chunk-ENQSB4OF.js.map +0 -1
  114. package/dist/chunk-UA7WNT2F.js.map +0 -1
@@ -0,0 +1,930 @@
1
+ import '../../server-only-guard.ts'
2
+ import { normalizeCartLines } from '../../cart/normalize.ts'
3
+ import type { CartProvider, CommerceProvider } from '../provider.ts'
4
+ import type { CheckoutCommerceProvider } from '../../checkout/types.ts'
5
+ import type {
6
+ CartItemView,
7
+ CartView,
8
+ CommerceImage,
9
+ CreatePendingOrderResult,
10
+ Order,
11
+ ShippingPolicy,
12
+ } from '../types.ts'
13
+ import {
14
+ mapSoftwareProductDetailResult,
15
+ mapSoftwareVariant,
16
+ type SoftwareProductDetailResultLike,
17
+ } from './software-mappers.ts'
18
+
19
+ type SoftwareProductListDoc = {
20
+ id: string | number
21
+ slug?: string | null
22
+ title?: string | null
23
+ description?: string | null
24
+ status?: string | null
25
+ }
26
+
27
+ type SoftwareVariantDoc = Parameters<typeof mapSoftwareVariant>[0] & {
28
+ product?: unknown
29
+ }
30
+
31
+ type SoftwareCommerceConfig = {
32
+ publishableKey: string
33
+ secretKey: string
34
+ apiUrl?: string
35
+ shippingPolicy: ShippingPolicy
36
+ }
37
+
38
+ type SoftwareEnv = NodeJS.ProcessEnv | Record<string, string | undefined>
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // SDK response shapes (narrowed). The real SDK types are richer; we narrow to
42
+ // the fields the storefront consumes and keep relation refs tolerant of
43
+ // id-only (depth 0) or populated objects.
44
+ // ---------------------------------------------------------------------------
45
+ type CartItemDoc = {
46
+ id?: string | number
47
+ product?: unknown
48
+ variant?: unknown
49
+ quantity?: number | null
50
+ unitPrice?: number | null
51
+ discountedTotalPrice?: number | null
52
+ requiresShipping?: boolean | null
53
+ }
54
+
55
+ type CartDoc = {
56
+ id?: string | number
57
+ currency?: string | null
58
+ subtotalAmount?: number | null
59
+ shippingAmount?: number | null
60
+ discountAmount?: number | null
61
+ totalAmount?: number | null
62
+ discountCode?: string | null
63
+ items?: CartItemDoc[] | null
64
+ }
65
+
66
+ type OrderItemDoc = {
67
+ product?: unknown
68
+ variant?: unknown
69
+ productTitle?: string | null
70
+ variantTitle?: string | null
71
+ quantity?: number | null
72
+ unitPrice?: number | null
73
+ totalPrice?: number | null
74
+ discountedTotalPrice?: number | null
75
+ }
76
+
77
+ type TransactionDoc = {
78
+ pgPaymentId?: string | null
79
+ pgProvider?: string | null
80
+ status?: string | null
81
+ amount?: number | null
82
+ }
83
+
84
+ type OrderDoc = {
85
+ id?: string | number
86
+ orderNumber?: string | null
87
+ financialStatus?: string | null
88
+ displayFinancialStatus?: string | null
89
+ displayStatus?: string | null
90
+ customerSnapshot?: {
91
+ name?: string | null
92
+ email?: string | null
93
+ phone?: string | null
94
+ } | null
95
+ shippingAddress?: {
96
+ recipientName?: string | null
97
+ phone?: string | null
98
+ postalCode?: string | null
99
+ address?: string | null
100
+ detailAddress?: string | null
101
+ deliveryMessage?: string | null
102
+ } | null
103
+ subtotalAmount?: number | null
104
+ shippingAmount?: number | null
105
+ totalAmount?: number | null
106
+ items?: OrderItemDoc[] | { docs?: OrderItemDoc[] | null } | null
107
+ }
108
+
109
+ type OrderWithTransaction = {
110
+ order: OrderDoc
111
+ transaction: TransactionDoc | null
112
+ }
113
+
114
+ type CustomerSnapshotInput = { name?: string; email: string; phone?: string }
115
+
116
+ /**
117
+ * The slice of the `@01.software/sdk` server client this adapter depends on.
118
+ * Declaring it explicitly is the SDK boundary: the real client is cast to it
119
+ * once in {@link createSoftwareCommerceProvider}, and tests can inject a fake to
120
+ * exercise the checkout → confirm round-trip without a live Console.
121
+ */
122
+ export type SoftwareSdkClientLike = {
123
+ collections: {
124
+ from: (slug: string) => {
125
+ find: (options: {
126
+ limit?: number
127
+ depth?: number
128
+ where?: unknown
129
+ joins?: boolean
130
+ }) => Promise<{ docs: unknown[]; totalDocs?: number }>
131
+ }
132
+ }
133
+ commerce: {
134
+ product: {
135
+ detail: (params: { slug: string }) => Promise<unknown>
136
+ stockCheck: (params: {
137
+ items: { variantId: string; quantity: number }[]
138
+ }) => Promise<{
139
+ allAvailable?: boolean
140
+ results: {
141
+ variantId: string | number
142
+ requestedQuantity: number
143
+ isUnlimited: boolean
144
+ availableStock: number | null
145
+ available: boolean
146
+ status: string
147
+ }[]
148
+ }>
149
+ }
150
+ cart: {
151
+ create: () => Promise<{ cartToken?: string }>
152
+ get: (cartToken: string) => Promise<CartDoc>
153
+ addItem: (params: {
154
+ cartToken: string
155
+ product: string
156
+ variant: string
157
+ option?: string
158
+ quantity: number
159
+ }) => Promise<unknown>
160
+ updateItem: (params: {
161
+ cartToken: string
162
+ cartItemId: string
163
+ quantity: number
164
+ }) => Promise<unknown>
165
+ removeItem: (params: {
166
+ cartToken: string
167
+ cartItemId: string
168
+ }) => Promise<unknown>
169
+ clear: (params: { cartToken: string }) => Promise<unknown>
170
+ }
171
+ orders: {
172
+ checkout: (params: {
173
+ cartId: string
174
+ cartToken?: string
175
+ orderNumber: string
176
+ customerSnapshot: CustomerSnapshotInput
177
+ shippingAddress?: {
178
+ recipientName: string
179
+ phone: string
180
+ postalCode: string
181
+ address: string
182
+ detailAddress: string
183
+ deliveryMessage?: string
184
+ }
185
+ idempotencyKey?: string
186
+ }) => Promise<OrderDoc>
187
+ confirmPaymentReturningOrder: (params: {
188
+ orderNumber?: string
189
+ pgPaymentId: string
190
+ pgProvider: string
191
+ amount: number
192
+ currency?: string
193
+ providerEventId?: string
194
+ confirmationSource?: string
195
+ idempotencyKey?: string
196
+ }) => Promise<OrderWithTransaction>
197
+ getByPaymentId: (params: {
198
+ pgProvider: string
199
+ pgPaymentId: string
200
+ }) => Promise<OrderWithTransaction>
201
+ updateTransaction: (params: {
202
+ pgPaymentId: string
203
+ status: string
204
+ }) => Promise<unknown>
205
+ cancelOrder: (params: {
206
+ orderNumber: string
207
+ reasonCode?: string
208
+ reasonDetail?: string
209
+ idempotencyKey?: string
210
+ }) => Promise<unknown>
211
+ }
212
+ }
213
+ }
214
+
215
+ export function createSoftwareCommerceProvider(): CommerceProvider {
216
+ const config = getSoftwareCommerceConfig(process.env)
217
+ const clientPromise = createSoftwareSdkClient(config)
218
+ return buildSoftwareCommerceProvider(
219
+ // The real server client is a structural superset of the slice we use.
220
+ clientPromise as unknown as Promise<SoftwareSdkClientLike>,
221
+ config.shippingPolicy,
222
+ )
223
+ }
224
+
225
+ export function buildSoftwareCommerceProvider(
226
+ clientPromise: Promise<SoftwareSdkClientLike>,
227
+ shippingPolicy: ShippingPolicy,
228
+ ): CommerceProvider {
229
+ const provider: CommerceProvider = {
230
+ async listProducts(input) {
231
+ const client = await clientPromise
232
+ const limit = input?.limit ?? 24
233
+ const productResponse = await client.collections.from('products').find({
234
+ limit,
235
+ depth: 1,
236
+ where: { status: { equals: 'published' } },
237
+ joins: false,
238
+ })
239
+
240
+ const productDetails = await Promise.allSettled(
241
+ (productResponse.docs as SoftwareProductListDoc[]).flatMap((product) =>
242
+ product.slug
243
+ ? [client.commerce.product.detail({ slug: product.slug })]
244
+ : [],
245
+ ),
246
+ )
247
+
248
+ const products = productDetails.flatMap((detail) =>
249
+ detail.status === 'fulfilled'
250
+ ? wrapOptional(
251
+ mapSoftwareProductDetailResult(
252
+ detail.value as unknown as SoftwareProductDetailResultLike,
253
+ ),
254
+ )
255
+ : [],
256
+ )
257
+
258
+ return {
259
+ products,
260
+ total: productResponse.totalDocs ?? products.length,
261
+ }
262
+ },
263
+
264
+ async getProductBySlug(slug) {
265
+ const client = await clientPromise
266
+ const result = await client.commerce.product.detail({ slug })
267
+ const detail = mapSoftwareProductDetailResult(
268
+ result as unknown as SoftwareProductDetailResultLike,
269
+ )
270
+ return detail?.product.status === 'published' ? detail : null
271
+ },
272
+
273
+ async getVariantsByIds(ids) {
274
+ const client = await clientPromise
275
+ const normalizedIds = [...new Set(ids.map(String).filter(Boolean))]
276
+ if (normalizedIds.length === 0) return []
277
+
278
+ const response = await client.collections.from('product-variants').find({
279
+ limit: normalizedIds.length,
280
+ depth: 2,
281
+ where: { id: { in: normalizedIds } },
282
+ })
283
+
284
+ return (response.docs as SoftwareVariantDoc[]).map((variant) =>
285
+ mapSoftwareVariant(variant, getRelationId(variant.product) ?? ''),
286
+ )
287
+ },
288
+
289
+ async getShippingPolicy() {
290
+ return shippingPolicy
291
+ },
292
+
293
+ async checkStock(items) {
294
+ const client = await clientPromise
295
+ const lines = normalizeCartLines(items)
296
+ if (lines.length === 0) {
297
+ return { ok: true, lines: [] }
298
+ }
299
+
300
+ const stock = await client.commerce.product.stockCheck({
301
+ items: lines.map((line) => ({
302
+ variantId: line.variantId,
303
+ quantity: line.quantity,
304
+ })),
305
+ })
306
+
307
+ return {
308
+ ok: Boolean(stock.allAvailable),
309
+ lines: stock.results.map((line) => ({
310
+ variantId: String(line.variantId),
311
+ quantity: line.requestedQuantity,
312
+ availableQuantity: line.isUnlimited ? null : line.availableStock,
313
+ ok: Boolean(line.available),
314
+ reason:
315
+ line.status === 'not_found'
316
+ ? 'variant_not_found'
317
+ : line.available
318
+ ? undefined
319
+ : 'insufficient_stock',
320
+ })),
321
+ }
322
+ },
323
+
324
+ async createCart() {
325
+ const client = await clientPromise
326
+ const created = await client.commerce.cart.create()
327
+ if (!created.cartToken) {
328
+ throw new Error('Cart create did not return a cartToken')
329
+ }
330
+ return { cartToken: created.cartToken }
331
+ },
332
+
333
+ async getCart(cartToken) {
334
+ const client = await clientPromise
335
+ try {
336
+ const cart = await client.commerce.cart.get(cartToken)
337
+ return enrichCart(cart)
338
+ } catch (error) {
339
+ // A missing/expired/completed cart resolves to "no active cart" so the
340
+ // storefront can mint a fresh one rather than surfacing a 404.
341
+ if (isNotFound(error)) return null
342
+ throw error
343
+ }
344
+ },
345
+
346
+ async addCartItem({ cartToken, item }) {
347
+ const client = await clientPromise
348
+ await client.commerce.cart.addItem({
349
+ cartToken,
350
+ product: item.productId,
351
+ variant: item.variantId,
352
+ ...(item.optionId ? { option: item.optionId } : {}),
353
+ quantity: item.quantity,
354
+ })
355
+ return getCartOrThrow(provider, cartToken)
356
+ },
357
+
358
+ async updateCartItem({ cartToken, cartItemId, quantity }) {
359
+ const client = await clientPromise
360
+ await client.commerce.cart.updateItem({ cartToken, cartItemId, quantity })
361
+ return getCartOrThrow(provider, cartToken)
362
+ },
363
+
364
+ async removeCartItem({ cartToken, cartItemId }) {
365
+ const client = await clientPromise
366
+ await client.commerce.cart.removeItem({ cartToken, cartItemId })
367
+ return getCartOrThrow(provider, cartToken)
368
+ },
369
+
370
+ async clearCart({ cartToken }) {
371
+ const client = await clientPromise
372
+ await client.commerce.cart.clear({ cartToken })
373
+ return getCartOrThrow(provider, cartToken)
374
+ },
375
+
376
+ async checkoutCart(input) {
377
+ const client = await clientPromise
378
+ const cart = await client.commerce.cart.get(input.cartToken)
379
+ const cartId = cart.id != null ? String(cart.id) : ''
380
+ const lines = cart.items ?? []
381
+ if (!cartId || lines.length === 0) {
382
+ throw new Error('Cart is empty')
383
+ }
384
+ const hasShippableItems = lines.some(
385
+ (item) => item.requiresShipping !== false,
386
+ )
387
+ if (hasShippableItems && !input.shippingAddress) {
388
+ throw new Error('Shipping address is required')
389
+ }
390
+
391
+ const orderNumber = generateOrderNumber()
392
+ // pgPaymentId is minted template-side AFTER checkout (the design's "mint
393
+ // after" rule): passing it to checkout would route into the Toss-only
394
+ // Console-owned-capture path. Checkout creates an OPEN Checkout only (no
395
+ // Order / Transaction yet); placement resolves it by `orderNumber`
396
+ // (→ `checkoutToken`) at confirm. The durable orderNumber↔pgPaymentId
397
+ // mapping is the Console Checkout/Transaction row after confirm — there is
398
+ // no local index.
399
+ const paymentId = `pay_${crypto.randomUUID()}`
400
+
401
+ const placed = await client.commerce.orders.checkout({
402
+ cartId,
403
+ cartToken: input.cartToken,
404
+ orderNumber,
405
+ customerSnapshot: {
406
+ name: input.customerSnapshot.name,
407
+ email: input.customerSnapshot.email,
408
+ phone: input.customerSnapshot.phone,
409
+ },
410
+ ...(input.shippingAddress
411
+ ? {
412
+ shippingAddress: {
413
+ recipientName: input.shippingAddress.recipientName,
414
+ phone: input.shippingAddress.phone,
415
+ postalCode: input.shippingAddress.postalCode,
416
+ address: input.shippingAddress.address,
417
+ detailAddress: input.shippingAddress.detailAddress,
418
+ deliveryMessage: input.shippingAddress.deliveryMessage,
419
+ },
420
+ }
421
+ : {}),
422
+ idempotencyKey: `checkout-${orderNumber}`,
423
+ })
424
+
425
+ const order = mapOrder(placed, {
426
+ fallbackOrderNumber: orderNumber,
427
+ contact: {
428
+ customerSnapshot: input.customerSnapshot,
429
+ ...(input.shippingAddress
430
+ ? { shippingAddress: input.shippingAddress }
431
+ : {}),
432
+ },
433
+ })
434
+
435
+ const amount = placed.totalAmount ?? cart.totalAmount ?? order.totalAmount
436
+
437
+ return {
438
+ order,
439
+ paymentId,
440
+ paymentName: buildPaymentName(
441
+ order.items.map((item) => item.productTitle),
442
+ ),
443
+ amount,
444
+ currency: cart.currency ?? shippingPolicy.currency,
445
+ } satisfies CreatePendingOrderResult
446
+ },
447
+
448
+ async getOrderByPaymentId({ paymentId, provider: pgProvider }) {
449
+ const client = await clientPromise
450
+ try {
451
+ const result = await client.commerce.orders.getByPaymentId({
452
+ pgProvider,
453
+ pgPaymentId: paymentId,
454
+ })
455
+ return mapOrder(result.order, {
456
+ transaction: result.transaction,
457
+ fallbackPaymentId: paymentId,
458
+ fallbackProvider: pgProvider,
459
+ })
460
+ } catch (error) {
461
+ // Before placement (confirm) there is no payment Transaction yet, so a
462
+ // by-payment lookup legitimately 404s. Callers treat null as "not
463
+ // placed yet".
464
+ if (isNotFound(error)) return null
465
+ throw error
466
+ }
467
+ },
468
+
469
+ async confirmOrderPayment(input) {
470
+ const client = await clientPromise
471
+ const result = await client.commerce.orders.confirmPaymentReturningOrder({
472
+ orderNumber: input.orderNumber,
473
+ pgPaymentId: input.paymentId,
474
+ pgProvider: input.provider,
475
+ amount: input.amount,
476
+ currency: shippingPolicy.currency,
477
+ providerEventId: input.providerEventId,
478
+ confirmationSource: input.providerEventId
479
+ ? 'provider_webhook'
480
+ : 'provider_lookup',
481
+ idempotencyKey: input.providerEventId ?? input.paymentId,
482
+ })
483
+
484
+ return mapOrder(result.order, {
485
+ transaction: result.transaction,
486
+ fallbackPaymentId: input.paymentId,
487
+ fallbackProvider: input.provider,
488
+ })
489
+ },
490
+
491
+ async markPaymentFailed(input) {
492
+ const client = await clientPromise
493
+ // The payment Transaction only exists once a checkout is placed. A failed
494
+ // PG attempt before placement leaves the Checkout to expire via the
495
+ // Console cron, so annotation is best-effort.
496
+ try {
497
+ await client.commerce.orders.updateTransaction({
498
+ pgPaymentId: input.paymentId,
499
+ status: 'failed',
500
+ })
501
+ } catch (error) {
502
+ if (!isNotFound(error)) throw error
503
+ }
504
+ return provider.getOrderByPaymentId({
505
+ paymentId: input.paymentId,
506
+ provider: input.provider,
507
+ })
508
+ },
509
+
510
+ async cancelPendingOrder(input) {
511
+ // Pre-placement there is no Order to cancel — the abandoned Checkout
512
+ // expires server-side. Only a placed order can be canceled.
513
+ const existing = await provider.getOrderByPaymentId({
514
+ paymentId: input.paymentId,
515
+ provider: input.provider,
516
+ })
517
+ if (!existing) return null
518
+
519
+ const client = await clientPromise
520
+ await client.commerce.orders.cancelOrder({
521
+ orderNumber: existing.orderNumber,
522
+ reasonCode: 'other',
523
+ reasonDetail: 'Payment canceled before completion',
524
+ idempotencyKey: input.providerEventId ?? input.paymentId,
525
+ })
526
+ return { ...existing, displayStatus: 'canceled' }
527
+ },
528
+ }
529
+
530
+ /**
531
+ * Map a Console cart (id-only relation refs at depth 0) into the storefront
532
+ * `CartView`, enriching each line with catalog title/image. Products are
533
+ * fetched only for the cart's distinct product ids in a single query — no
534
+ * full-catalog scan and no per-product `detail` fan-out.
535
+ */
536
+ async function enrichCart(cart: CartDoc): Promise<CartView> {
537
+ const client = await clientPromise
538
+ const rawItems = cart.items ?? []
539
+ const variantIds = rawItems
540
+ .map((item) => getRelationId(item.variant))
541
+ .filter((id): id is string => Boolean(id))
542
+
543
+ const variants =
544
+ variantIds.length > 0 ? await provider.getVariantsByIds(variantIds) : []
545
+ const variantById = new Map(
546
+ variants.map((variant) => [variant.id, variant]),
547
+ )
548
+
549
+ const productIds = [
550
+ ...new Set(variants.map((variant) => variant.productId).filter(Boolean)),
551
+ ]
552
+ const productById =
553
+ productIds.length > 0
554
+ ? await fetchProductSummaries(client, productIds)
555
+ : new Map<string, ProductSummary>()
556
+
557
+ const items: CartItemView[] = rawItems.flatMap((item) => {
558
+ const cartItemId = item.id != null ? String(item.id) : ''
559
+ const variantId = getRelationId(item.variant) ?? ''
560
+ if (!cartItemId || !variantId) return []
561
+
562
+ const variant = variantById.get(variantId)
563
+ const productId = variant?.productId ?? getRelationId(item.product) ?? ''
564
+ const summary = productById.get(productId)
565
+ const quantity = item.quantity ?? 0
566
+ const unitAmount = item.unitPrice ?? variant?.price ?? 0
567
+ const lineAmount = item.discountedTotalPrice ?? unitAmount * quantity
568
+
569
+ return [
570
+ {
571
+ cartItemId,
572
+ productId,
573
+ variantId,
574
+ quantity,
575
+ unitAmount,
576
+ lineAmount,
577
+ ...(item.requiresShipping !== undefined
578
+ ? { requiresShipping: item.requiresShipping }
579
+ : {}),
580
+ productTitle: summary?.title ?? 'Product',
581
+ variantTitle: variant?.title,
582
+ image: variant?.images[0] ?? summary?.image ?? null,
583
+ },
584
+ ]
585
+ })
586
+
587
+ return {
588
+ id: cart.id != null ? String(cart.id) : '',
589
+ currency: cart.currency ?? shippingPolicy.currency,
590
+ subtotalAmount: cart.subtotalAmount ?? 0,
591
+ shippingAmount: cart.shippingAmount ?? 0,
592
+ discountAmount: cart.discountAmount ?? 0,
593
+ totalAmount: cart.totalAmount ?? 0,
594
+ ...(cart.discountCode ? { discountCode: cart.discountCode } : {}),
595
+ items,
596
+ }
597
+ }
598
+
599
+ return provider
600
+ }
601
+
602
+ async function createSoftwareSdkClient(config: SoftwareCommerceConfig) {
603
+ const { createServerClient } = await import('@01.software/sdk/server')
604
+ return createServerClient({
605
+ publishableKey: config.publishableKey,
606
+ secretKey: config.secretKey,
607
+ apiUrl: config.apiUrl,
608
+ })
609
+ }
610
+
611
+ /**
612
+ * Per-request **customer-JWT-scoped** commerce client (browser SDK, no secret
613
+ * key — the JWT is the only credential, so cart writes bind to and re-verify the
614
+ * customer). This is the same client family as `lib/customer/client.server.ts`;
615
+ * here we consume its `commerce`/`collections` surface for cart ops.
616
+ */
617
+ async function createSoftwareCustomerCommerceClient(
618
+ config: SoftwareCommerceConfig,
619
+ token: string,
620
+ ) {
621
+ const { createClient } = await import('@01.software/sdk')
622
+ return createClient({
623
+ publishableKey: config.publishableKey,
624
+ apiUrl: config.apiUrl,
625
+ customer: { persist: false, token },
626
+ })
627
+ }
628
+
629
+ /**
630
+ * Build the shared provider around a customer-JWT client. Cart reads/writes and
631
+ * checkout run as the customer (publishable key + JWT, no secret key), so the
632
+ * Console can bind new carts to the customer and reject checkout for carts owned
633
+ * by another customer.
634
+ */
635
+ function createSoftwareCustomerProvider(token: string): CommerceProvider {
636
+ const config = getSoftwareCommerceConfig(process.env)
637
+ const clientPromise = createSoftwareCustomerCommerceClient(config, token)
638
+ return buildSoftwareCommerceProvider(
639
+ clientPromise as unknown as Promise<SoftwareSdkClientLike>,
640
+ config.shippingPolicy,
641
+ )
642
+ }
643
+
644
+ /**
645
+ * A {@link CartProvider} whose cart reads/writes go through the customer's JWT
646
+ * client. Logged-in `createCart()` therefore mints a **customer-bound** cart
647
+ * (the JWT auto-binds `customer`), never a guest cart.
648
+ */
649
+ export function createSoftwareCustomerCartProvider(
650
+ token: string,
651
+ ): CartProvider {
652
+ return createSoftwareCustomerProvider(token)
653
+ }
654
+
655
+ /**
656
+ * Checkout-only customer provider for route handlers. Exposing only
657
+ * `checkoutCart` prevents unrelated payment lifecycle operations from being
658
+ * accidentally run through the customer client.
659
+ */
660
+ export function createSoftwareCustomerCheckoutProvider(
661
+ token: string,
662
+ ): CheckoutCommerceProvider {
663
+ return createSoftwareCustomerProvider(token)
664
+ }
665
+
666
+ export function getSoftwareCommerceConfig(
667
+ env: SoftwareEnv,
668
+ ): SoftwareCommerceConfig {
669
+ const publishableKey = env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY?.trim()
670
+ const secretKey = env.SOFTWARE_SECRET_KEY?.trim()
671
+ if (!publishableKey) {
672
+ throw new Error(
673
+ '01.software commerce requires NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY.',
674
+ )
675
+ }
676
+ if (!secretKey) {
677
+ throw new Error('01.software commerce requires SOFTWARE_SECRET_KEY.')
678
+ }
679
+
680
+ const apiUrl =
681
+ env.SOFTWARE_API_URL?.trim() || env.NEXT_PUBLIC_SOFTWARE_API_URL?.trim()
682
+ const baseAmount = readMoneyEnv(env.SOFTWARE_SHIPPING_AMOUNT, 0)
683
+ const freeAboveAmount = readOptionalMoneyEnv(
684
+ env.SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT,
685
+ )
686
+
687
+ return {
688
+ publishableKey,
689
+ secretKey,
690
+ apiUrl: apiUrl || undefined,
691
+ shippingPolicy: {
692
+ currency: 'KRW',
693
+ baseAmount,
694
+ ...(freeAboveAmount == null ? {} : { freeAboveAmount }),
695
+ },
696
+ }
697
+ }
698
+
699
+ async function getCartOrThrow(
700
+ provider: CommerceProvider,
701
+ cartToken: string,
702
+ ): Promise<CartView> {
703
+ const cart = await provider.getCart(cartToken)
704
+ if (!cart) throw new Error('Cart not found')
705
+ return cart
706
+ }
707
+
708
+ type SoftwareMediaRef =
709
+ | string
710
+ | number
711
+ | {
712
+ url?: string | null
713
+ alt?: string | null
714
+ width?: number | null
715
+ height?: number | null
716
+ }
717
+ | null
718
+
719
+ type SoftwareProductSummaryDoc = {
720
+ id?: string | number
721
+ title?: string | null
722
+ thumbnail?: SoftwareMediaRef
723
+ images?: SoftwareMediaRef[] | null
724
+ }
725
+
726
+ type ProductSummary = { title: string; image: CommerceImage | null }
727
+
728
+ /**
729
+ * Fetch title + representative image for exactly the cart's distinct products
730
+ * in one tenant-scoped query (depth 1 populates the media relations). Avoids
731
+ * the full-catalog `listProducts` scan and its per-product `detail` fan-out.
732
+ */
733
+ async function fetchProductSummaries(
734
+ client: SoftwareSdkClientLike,
735
+ productIds: string[],
736
+ ): Promise<Map<string, ProductSummary>> {
737
+ const response = await client.collections.from('products').find({
738
+ limit: productIds.length,
739
+ depth: 1,
740
+ where: { id: { in: productIds } },
741
+ })
742
+
743
+ const summaries = new Map<string, ProductSummary>()
744
+ for (const doc of response.docs as SoftwareProductSummaryDoc[]) {
745
+ const id = doc.id != null ? String(doc.id) : ''
746
+ if (!id) continue
747
+ summaries.set(id, {
748
+ title: doc.title ?? 'Product',
749
+ image: toImage(doc.thumbnail) ?? toImage(doc.images?.[0]),
750
+ })
751
+ }
752
+ return summaries
753
+ }
754
+
755
+ function toImage(media: SoftwareMediaRef | undefined): CommerceImage | null {
756
+ if (!media || typeof media !== 'object' || !media.url) return null
757
+ return {
758
+ url: media.url,
759
+ alt: media.alt ?? undefined,
760
+ width: media.width ?? undefined,
761
+ height: media.height ?? undefined,
762
+ }
763
+ }
764
+
765
+ function mapOrder(
766
+ order: OrderDoc,
767
+ opts: {
768
+ transaction?: TransactionDoc | null
769
+ fallbackPaymentId?: string
770
+ fallbackProvider?: string
771
+ fallbackOrderNumber?: string
772
+ contact?: {
773
+ customerSnapshot: Order['customerSnapshot']
774
+ shippingAddress?: Order['shippingAddress']
775
+ }
776
+ } = {},
777
+ ): Order {
778
+ const rawItems = Array.isArray(order.items)
779
+ ? order.items
780
+ : (order.items?.docs ?? [])
781
+
782
+ const transactions: Order['transactions'] = opts.transaction
783
+ ? [
784
+ {
785
+ paymentId:
786
+ opts.transaction.pgPaymentId ?? opts.fallbackPaymentId ?? '',
787
+ provider:
788
+ opts.transaction.pgProvider ?? opts.fallbackProvider ?? 'unknown',
789
+ status: normalizeTransactionStatus(opts.transaction.status),
790
+ amount: opts.transaction.amount ?? order.totalAmount ?? 0,
791
+ },
792
+ ]
793
+ : []
794
+
795
+ return {
796
+ id: order.id != null ? String(order.id) : '',
797
+ orderNumber: order.orderNumber ?? opts.fallbackOrderNumber ?? '',
798
+ displayStatus: deriveDisplayStatus(order),
799
+ items: rawItems.map((item) => ({
800
+ variantId: getRelationId(item.variant) ?? '',
801
+ productTitle: item.productTitle ?? 'Product',
802
+ variantTitle: item.variantTitle ?? undefined,
803
+ quantity: item.quantity ?? 0,
804
+ unitAmount: item.unitPrice ?? 0,
805
+ lineAmount: item.totalPrice ?? item.discountedTotalPrice ?? 0,
806
+ })),
807
+ customerSnapshot: {
808
+ name:
809
+ order.customerSnapshot?.name ??
810
+ opts.contact?.customerSnapshot.name ??
811
+ '',
812
+ email:
813
+ order.customerSnapshot?.email ??
814
+ opts.contact?.customerSnapshot.email ??
815
+ '',
816
+ phone:
817
+ order.customerSnapshot?.phone ??
818
+ opts.contact?.customerSnapshot.phone ??
819
+ '',
820
+ },
821
+ ...(order.shippingAddress || opts.contact?.shippingAddress
822
+ ? {
823
+ shippingAddress: {
824
+ recipientName:
825
+ order.shippingAddress?.recipientName ??
826
+ opts.contact?.shippingAddress?.recipientName ??
827
+ '',
828
+ phone:
829
+ order.shippingAddress?.phone ??
830
+ opts.contact?.shippingAddress?.phone ??
831
+ '',
832
+ postalCode:
833
+ order.shippingAddress?.postalCode ??
834
+ opts.contact?.shippingAddress?.postalCode ??
835
+ '',
836
+ address:
837
+ order.shippingAddress?.address ??
838
+ opts.contact?.shippingAddress?.address ??
839
+ '',
840
+ detailAddress:
841
+ order.shippingAddress?.detailAddress ??
842
+ opts.contact?.shippingAddress?.detailAddress ??
843
+ '',
844
+ deliveryMessage:
845
+ order.shippingAddress?.deliveryMessage ??
846
+ opts.contact?.shippingAddress?.deliveryMessage ??
847
+ '',
848
+ },
849
+ }
850
+ : {}),
851
+ subtotalAmount: order.subtotalAmount ?? 0,
852
+ shippingAmount: order.shippingAmount ?? 0,
853
+ totalAmount: order.totalAmount ?? 0,
854
+ transactions,
855
+ }
856
+ }
857
+
858
+ function deriveDisplayStatus(order: OrderDoc): Order['displayStatus'] {
859
+ const financial = order.displayFinancialStatus ?? order.financialStatus
860
+ if (financial === 'paid' || financial === 'partially_refunded') return 'paid'
861
+ if (
862
+ financial === 'canceled' ||
863
+ financial === 'voided' ||
864
+ financial === 'refunded'
865
+ ) {
866
+ return 'canceled'
867
+ }
868
+ if (order.displayStatus === 'paid') return 'paid'
869
+ if (order.displayStatus === 'canceled') return 'canceled'
870
+ return 'pending'
871
+ }
872
+
873
+ function normalizeTransactionStatus(
874
+ status: string | null | undefined,
875
+ ): Order['transactions'][number]['status'] {
876
+ if (status === 'paid') return 'paid'
877
+ if (status === 'failed') return 'failed'
878
+ // `refunded`/`canceled` both read as canceled on the storefront transaction.
879
+ if (status === 'canceled' || status === 'refunded') return 'canceled'
880
+ return 'pending'
881
+ }
882
+
883
+ function isNotFound(error: unknown): boolean {
884
+ if (!error || typeof error !== 'object') return false
885
+ const status = (error as { status?: number }).status
886
+ const name = (error as { name?: string }).name
887
+ return status === 404 || name === 'NotFoundError'
888
+ }
889
+
890
+ function readMoneyEnv(value: string | undefined, fallback: number): number {
891
+ if (value == null || value.trim() === '') return fallback
892
+ const parsed = Number(value)
893
+ if (!Number.isFinite(parsed) || parsed < 0) {
894
+ throw new Error(`Expected a non-negative number, received ${value}.`)
895
+ }
896
+ return Math.round(parsed)
897
+ }
898
+
899
+ function readOptionalMoneyEnv(value: string | undefined): number | undefined {
900
+ if (value == null || value.trim() === '') return undefined
901
+ return readMoneyEnv(value, 0)
902
+ }
903
+
904
+ function getRelationId(value: unknown): string | null {
905
+ if (value == null) return null
906
+ if (typeof value === 'string' || typeof value === 'number')
907
+ return String(value)
908
+ if (typeof value === 'object' && 'id' in value) {
909
+ const id = (value as { id?: unknown }).id
910
+ if (typeof id === 'string' || typeof id === 'number') return String(id)
911
+ }
912
+ return null
913
+ }
914
+
915
+ function buildPaymentName(productTitles: string[]): string {
916
+ const [first, ...rest] = productTitles
917
+ if (!first) return 'Order'
918
+ return rest.length === 0 ? first : `${first} and ${rest.length} more`
919
+ }
920
+
921
+ function wrapOptional<T>(value: T | null | undefined): T[] {
922
+ return value == null ? [] : [value]
923
+ }
924
+
925
+ function generateOrderNumber(): string {
926
+ return `ORD-${Date.now().toString(36).toUpperCase()}-${crypto
927
+ .randomUUID()
928
+ .slice(0, 8)
929
+ .toUpperCase()}`
930
+ }