@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,267 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useRouter } from "next/navigation";
5
+ import { useState } from "react";
6
+ import { paymentProviderLabel } from "@/app-config";
7
+ import { useCart } from "@/lib/cart/use-cart";
8
+ import type { CustomerSnapshot, ShippingAddress } from "@/lib/commerce/types";
9
+ import type { ClientPaymentRequest } from "@/lib/payment/types";
10
+
11
+ type CheckoutResponse = {
12
+ orderNumber: string;
13
+ paymentId: string;
14
+ amount: number;
15
+ currency: string;
16
+ redirectUrl?: string;
17
+ clientPayment?: ClientPaymentRequest;
18
+ };
19
+
20
+ export function CheckoutForm() {
21
+ const router = useRouter();
22
+ const { cart, loading, reset } = useCart();
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [submitting, setSubmitting] = useState(false);
25
+ const providerLabel = paymentProviderLabel();
26
+
27
+ async function submit(formData: FormData) {
28
+ setSubmitting(true);
29
+ setError(null);
30
+
31
+ const customerSnapshot: CustomerSnapshot = {
32
+ name: value(formData, "customerName"),
33
+ email: value(formData, "email"),
34
+ phone: value(formData, "phone"),
35
+ };
36
+ const shippingAddress: ShippingAddress = {
37
+ recipientName: value(formData, "recipientName"),
38
+ phone: value(formData, "shippingPhone"),
39
+ postalCode: value(formData, "postalCode"),
40
+ address: value(formData, "address"),
41
+ detailAddress: value(formData, "detailAddress"),
42
+ deliveryMessage: value(formData, "deliveryMessage"),
43
+ };
44
+
45
+ const response = await fetch("/api/checkout", {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ customerSnapshot, shippingAddress }),
49
+ });
50
+
51
+ if (!response.ok) {
52
+ const payload = (await response.json()) as { message?: string };
53
+ setError(payload.message ?? "Checkout failed");
54
+ setSubmitting(false);
55
+ return;
56
+ }
57
+
58
+ const payload = (await response.json()) as CheckoutResponse;
59
+ // scaffold:provider:portone:start
60
+ if (payload.clientPayment?.provider === "portone") {
61
+ try {
62
+ await requestPortOnePayment(payload.clientPayment);
63
+ reset();
64
+ } catch (error) {
65
+ setError(error instanceof Error ? error.message : "Payment failed");
66
+ setSubmitting(false);
67
+ }
68
+ return;
69
+ }
70
+ // scaffold:provider:portone:end
71
+ // scaffold:provider:tosspayments:start
72
+ if (payload.clientPayment?.provider === "tosspayments") {
73
+ try {
74
+ await requestTossPayments(payload.clientPayment);
75
+ reset();
76
+ } catch (error) {
77
+ setError(error instanceof Error ? error.message : "Payment failed");
78
+ setSubmitting(false);
79
+ }
80
+ return;
81
+ }
82
+ // scaffold:provider:tosspayments:end
83
+
84
+ reset();
85
+ router.push(
86
+ payload.redirectUrl ??
87
+ `/checkout/success?paymentId=${encodeURIComponent(payload.paymentId)}&orderNumber=${encodeURIComponent(payload.orderNumber)}`,
88
+ );
89
+ }
90
+
91
+ if (!loading && (!cart || cart.items.length === 0)) {
92
+ return (
93
+ <section>
94
+ <h1>Cart required</h1>
95
+ <p>Add at least one item before checkout.</p>
96
+ <Link href="/products">Browse products</Link>
97
+ </section>
98
+ );
99
+ }
100
+
101
+ return (
102
+ <form action={submit} aria-describedby="checkout-summary">
103
+ <section aria-labelledby="checkout-title">
104
+ <h1 id="checkout-title">Review order details</h1>
105
+ <p id="checkout-summary">
106
+ Customer and shipping details are collected before payment handoff.
107
+ </p>
108
+
109
+ <fieldset>
110
+ <legend>Customer</legend>
111
+ <Field name="customerName" label="Customer name" autoComplete="name" />
112
+ <Field name="email" label="Email" type="email" autoComplete="email" />
113
+ <Field name="phone" label="Phone" autoComplete="tel" />
114
+ </fieldset>
115
+
116
+ <fieldset>
117
+ <legend>Shipping</legend>
118
+ <Field name="recipientName" label="Recipient name" autoComplete="name" />
119
+ <Field name="shippingPhone" label="Shipping phone" autoComplete="tel" />
120
+ <Field name="postalCode" label="Postal code" autoComplete="postal-code" />
121
+ <Field name="address" label="Address" autoComplete="street-address" />
122
+ <Field name="detailAddress" label="Address detail" />
123
+ <Field name="deliveryMessage" label="Shipping message" required={false} />
124
+ </fieldset>
125
+ </section>
126
+
127
+ <aside>
128
+ <h2>Payment handoff</h2>
129
+ <p>
130
+ Local demo payments complete immediately. Configured {providerLabel}{" "}
131
+ credentials start the provider handoff after the order is created.
132
+ </p>
133
+ <dl>
134
+ <div>
135
+ <dt>Order lines</dt>
136
+ <dd>Current cart</dd>
137
+ </div>
138
+ <div>
139
+ <dt>Price check</dt>
140
+ <dd>Before payment</dd>
141
+ </div>
142
+ <div>
143
+ <dt>Provider</dt>
144
+ <dd>{providerLabel}</dd>
145
+ </div>
146
+ </dl>
147
+ {error ? <p role="alert">{error}</p> : null}
148
+ <button type="submit" disabled={submitting}>
149
+ {submitting ? "Creating order..." : "Start payment"}
150
+ </button>
151
+ <p aria-live="polite">{submitting ? "Creating order and payment request." : null}</p>
152
+ </aside>
153
+ </form>
154
+ );
155
+ }
156
+
157
+ // scaffold:provider:portone:start
158
+ async function requestPortOnePayment(payment: Extract<ClientPaymentRequest, { provider: "portone" }>) {
159
+ const PortOne = await import("@portone/browser-sdk/v2");
160
+ const redirectUrl = new URL("/api/checkout/payment-return", window.location.origin);
161
+ redirectUrl.searchParams.set("paymentId", payment.paymentId);
162
+ redirectUrl.searchParams.set("orderNumber", payment.orderNumber);
163
+
164
+ const result = await PortOne.requestPayment({
165
+ storeId: payment.storeId,
166
+ channelKey: payment.channelKey,
167
+ paymentId: payment.paymentId,
168
+ orderName: payment.orderName,
169
+ totalAmount: payment.amount,
170
+ currency: payment.currency,
171
+ payMethod: payment.payMethod,
172
+ customer: {
173
+ fullName: payment.customer.name,
174
+ email: payment.customer.email,
175
+ phoneNumber: payment.customer.phone,
176
+ },
177
+ redirectUrl: redirectUrl.toString(),
178
+ forceRedirect: true,
179
+ // Durable orderNumber↔pgPaymentId mapping carried on the payment itself, so
180
+ // a webhook arriving before/instead of this redirect can resolve the open
181
+ // checkout via a server re-fetch (#1544 / I6). No local index.
182
+ customData: { orderNumber: payment.orderNumber },
183
+ } as Parameters<typeof PortOne.requestPayment>[0]);
184
+
185
+ if (result?.code) {
186
+ throw new Error(result.message ?? result.code);
187
+ }
188
+
189
+ if (result?.paymentId) {
190
+ window.location.assign(redirectUrl.toString());
191
+ }
192
+ }
193
+ // scaffold:provider:portone:end
194
+
195
+ // scaffold:provider:tosspayments:start
196
+ async function requestTossPayments(
197
+ payment: Extract<ClientPaymentRequest, { provider: "tosspayments" }>,
198
+ ) {
199
+ const { ANONYMOUS, loadTossPayments } = await import(
200
+ "@tosspayments/tosspayments-sdk"
201
+ );
202
+ const tossPayments = await loadTossPayments(payment.clientKey);
203
+ const checkoutReturnUrl = new URL(
204
+ "/api/checkout/payment-return",
205
+ window.location.origin,
206
+ );
207
+ checkoutReturnUrl.searchParams.set("paymentId", payment.paymentId);
208
+ checkoutReturnUrl.searchParams.set("orderNumber", payment.orderNumber);
209
+
210
+ const failUrl = new URL("/checkout", window.location.origin);
211
+ failUrl.searchParams.set("paymentId", payment.paymentId);
212
+ failUrl.searchParams.set("orderNumber", payment.orderNumber);
213
+
214
+ const paymentClient = tossPayments.payment({
215
+ customerKey: payment.customerKey ?? ANONYMOUS,
216
+ });
217
+
218
+ await paymentClient.requestPayment({
219
+ method: "CARD",
220
+ amount: {
221
+ currency: payment.currency,
222
+ value: payment.amount,
223
+ },
224
+ orderId: payment.paymentId,
225
+ orderName: payment.orderName,
226
+ successUrl: checkoutReturnUrl.toString(),
227
+ failUrl: failUrl.toString(),
228
+ customerEmail: payment.customer.email,
229
+ customerName: payment.customer.name,
230
+ customerMobilePhone: payment.customer.phone,
231
+ // Durable orderNumber↔pgPaymentId mapping carried on the payment itself, so
232
+ // a webhook arriving before/instead of this redirect can resolve the open
233
+ // checkout via a server re-fetch (#1544 / I6). No local index.
234
+ metadata: { orderNumber: payment.orderNumber },
235
+ });
236
+ }
237
+ // scaffold:provider:tosspayments:end
238
+
239
+ function Field({
240
+ label,
241
+ name,
242
+ type = "text",
243
+ autoComplete,
244
+ required = true,
245
+ }: {
246
+ label: string;
247
+ name: string;
248
+ type?: string;
249
+ autoComplete?: string;
250
+ required?: boolean;
251
+ }) {
252
+ return (
253
+ <label>
254
+ {label}
255
+ <input
256
+ name={name}
257
+ type={type}
258
+ autoComplete={autoComplete}
259
+ required={required}
260
+ />
261
+ </label>
262
+ );
263
+ }
264
+
265
+ function value(formData: FormData, key: string): string {
266
+ return String(formData.get(key) ?? "").trim();
267
+ }
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useRef, useState } from "react";
5
+
6
+ type ReconcileStatus =
7
+ | "paid"
8
+ | "pending"
9
+ | "failed"
10
+ | "canceled"
11
+ | "payment_not_found";
12
+
13
+ type ViewState = ReconcileStatus | "loading" | "error";
14
+
15
+ /**
16
+ * Client trigger that reconciles the order once on mount via the reconcile
17
+ * route handler, keeping the mutation off the success-page RSC render
18
+ * (#1544 / I6). The `started` ref makes it fire exactly once even under React
19
+ * StrictMode's double-mount; `syncOrderPayment` is idempotent regardless.
20
+ */
21
+ export function CheckoutReconcile({
22
+ paymentId,
23
+ orderNumber,
24
+ }: {
25
+ paymentId: string;
26
+ orderNumber?: string;
27
+ }) {
28
+ const [state, setState] = useState<ViewState>("loading");
29
+ const started = useRef(false);
30
+
31
+ useEffect(() => {
32
+ if (started.current) return;
33
+ started.current = true;
34
+
35
+ void (async () => {
36
+ try {
37
+ const response = await fetch("/api/checkout/reconcile", {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify({ paymentId, orderNumber }),
41
+ });
42
+ if (!response.ok) {
43
+ setState("error");
44
+ return;
45
+ }
46
+ const data = (await response.json()) as { status: ReconcileStatus };
47
+ setState(data.status);
48
+ } catch {
49
+ setState("error");
50
+ }
51
+ })();
52
+ }, [paymentId, orderNumber]);
53
+
54
+ return (
55
+ <section>
56
+ <h1>{titleForState(state)}</h1>
57
+ <p>{bodyForState(state)}</p>
58
+ <Link href="/products">Continue shopping</Link>
59
+ </section>
60
+ );
61
+ }
62
+
63
+ function titleForState(state: ViewState): string {
64
+ if (state === "loading") return "Confirming payment…";
65
+ if (state === "paid") return "Payment confirmed";
66
+ if (state === "pending") return "Payment pending";
67
+ if (state === "payment_not_found") return "Payment not found";
68
+ if (state === "error") return "Could not confirm payment";
69
+ return "Payment not completed";
70
+ }
71
+
72
+ function bodyForState(state: ViewState): string {
73
+ if (state === "loading") return "Reconciling your order with the payment provider…";
74
+ if (state === "error") {
75
+ return "We could not confirm this payment. If you were charged, it will be reconciled automatically.";
76
+ }
77
+ return `Payment sync result: ${state}`;
78
+ }
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { useRouter } from "next/navigation";
4
+ import Link from "next/link";
5
+ import { useState } from "react";
6
+ import type { CustomerSummary } from "@/lib/customer/auth-actions";
7
+
8
+ /**
9
+ * Header account affordance. Renders login/register links for guests and the
10
+ * customer name + logout for an authenticated session. Logout posts JSON to the
11
+ * CSRF-guarded route, then refreshes so the server re-renders as a guest.
12
+ */
13
+ export function AccountNav({ customer }: { customer: CustomerSummary | null }) {
14
+ const router = useRouter();
15
+ const [pending, setPending] = useState(false);
16
+
17
+ if (!customer) {
18
+ return (
19
+ <>
20
+ <Link href="/login">Login</Link>
21
+ <Link href="/register">Register</Link>
22
+ </>
23
+ );
24
+ }
25
+
26
+ async function logout() {
27
+ setPending(true);
28
+ try {
29
+ await fetch("/api/auth/logout", {
30
+ method: "POST",
31
+ headers: { "Content-Type": "application/json" },
32
+ body: "{}",
33
+ });
34
+ router.refresh();
35
+ } finally {
36
+ setPending(false);
37
+ }
38
+ }
39
+
40
+ return (
41
+ <>
42
+ <span>{customer.name}</span>
43
+ <button type="button" onClick={logout} disabled={pending}>
44
+ {pending ? "Logging out…" : "Logout"}
45
+ </button>
46
+ </>
47
+ );
48
+ }
@@ -0,0 +1,12 @@
1
+ import { getCurrentCustomer } from "@/lib/customer/current-customer";
2
+ import { AccountNav } from "./account-nav";
3
+
4
+ /**
5
+ * Async server boundary for the header's account state. Kept separate from
6
+ * `SiteHeader` so the guest-only build (customer accounts pruned at scaffold
7
+ * time) leaves a plain synchronous header with no dangling `await`.
8
+ */
9
+ export async function AccountSlot() {
10
+ const customer = await getCurrentCustomer();
11
+ return <AccountNav customer={customer} />;
12
+ }
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useCart } from "@/lib/cart/use-cart";
5
+
6
+ export function CartLink() {
7
+ const { itemCount } = useCart();
8
+ return (
9
+ <Link href="/cart">
10
+ Cart{itemCount > 0 ? ` (${itemCount})` : ""}
11
+ </Link>
12
+ );
13
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from "react";
2
+ import { SiteHeader } from "./site-header";
3
+
4
+ export function PageShell({ children }: { children: ReactNode }) {
5
+ return (
6
+ <>
7
+ <SiteHeader />
8
+ <main>{children}</main>
9
+ </>
10
+ );
11
+ }
@@ -0,0 +1,22 @@
1
+ import Link from "next/link";
2
+ // scaffold:customer:start
3
+ import { AccountSlot } from "./account-slot";
4
+ // scaffold:customer:end
5
+ import { CartLink } from "./cart-link";
6
+
7
+ export function SiteHeader() {
8
+ return (
9
+ <header>
10
+ <div>
11
+ <Link href="/products">Console Commerce</Link>
12
+ <nav aria-label="Storefront">
13
+ <Link href="/products">Products</Link>
14
+ <CartLink />
15
+ {/* scaffold:customer:start */}
16
+ <AccountSlot />
17
+ {/* scaffold:customer:end */}
18
+ </nav>
19
+ </div>
20
+ </header>
21
+ );
22
+ }
@@ -0,0 +1,116 @@
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { useCart } from "@/lib/cart/use-cart";
5
+ import { formatMoney } from "@/lib/format";
6
+ import { resolveVariantFromSelection } from "@/lib/commerce/variant-selection.ts";
7
+ import type { ProductDetail } from "@/lib/commerce/types";
8
+
9
+ export function AddToCart({ detail }: { detail: ProductDetail }) {
10
+ const firstVariant = detail.variants[0];
11
+ const [selection, setSelection] = useState<Record<string, string>>(() =>
12
+ Object.fromEntries(
13
+ firstVariant?.optionValues.map((value) => [value.optionId, value.valueId]) ?? [],
14
+ ),
15
+ );
16
+ const [quantity, setQuantity] = useState(1);
17
+ const [added, setAdded] = useState(false);
18
+ const [pending, setPending] = useState(false);
19
+ const { addItem, error } = useCart();
20
+
21
+ const selectedVariant = useMemo(
22
+ () => resolveVariantFromSelection(detail, selection),
23
+ [detail, selection],
24
+ );
25
+ const availableQuantity = selectedVariant?.isUnlimited
26
+ ? null
27
+ : Math.max(0, (selectedVariant?.stock ?? 0) - (selectedVariant?.reservedStock ?? 0));
28
+ const isAvailable = selectedVariant
29
+ ? availableQuantity === null || availableQuantity >= quantity
30
+ : false;
31
+
32
+ if (!firstVariant) {
33
+ return (
34
+ <section aria-labelledby="purchase-options">
35
+ <h2 id="purchase-options">Purchase options</h2>
36
+ <p>Unavailable</p>
37
+ <button type="button" disabled>
38
+ No variants available
39
+ </button>
40
+ </section>
41
+ );
42
+ }
43
+
44
+ return (
45
+ <section aria-labelledby="purchase-options">
46
+ <h2 id="purchase-options">Purchase options</h2>
47
+ <dl>
48
+ <dt>Selected price</dt>
49
+ <dd>
50
+ <output>{selectedVariant ? formatMoney(selectedVariant.price) : "Select options"}</output>
51
+ </dd>
52
+ <dt>Stock</dt>
53
+ <dd>{availableQuantity === null ? "Unlimited" : availableQuantity}</dd>
54
+ </dl>
55
+
56
+ {detail.options.map((option) => (
57
+ <fieldset key={option.id}>
58
+ <legend>{option.title}</legend>
59
+ <div>
60
+ {option.values.map((value) => {
61
+ const active = selection[option.id] === value.id;
62
+ return (
63
+ <button
64
+ key={value.id}
65
+ type="button"
66
+ onClick={() =>
67
+ setSelection((current) => ({
68
+ ...current,
69
+ [option.id]: value.id,
70
+ }))
71
+ }
72
+ aria-pressed={active}
73
+ >
74
+ {value.value}
75
+ </button>
76
+ );
77
+ })}
78
+ </div>
79
+ </fieldset>
80
+ ))}
81
+
82
+ <label>
83
+ Quantity
84
+ <input
85
+ type="number"
86
+ min="1"
87
+ max="99"
88
+ value={quantity}
89
+ aria-label="Quantity"
90
+ onChange={(event) => setQuantity(Math.max(1, Number(event.target.value) || 1))}
91
+ />
92
+ </label>
93
+
94
+ <button
95
+ type="button"
96
+ disabled={!selectedVariant || !isAvailable || pending}
97
+ onClick={async () => {
98
+ if (!selectedVariant) return;
99
+ setAdded(false);
100
+ setPending(true);
101
+ const ok = await addItem({
102
+ productId: detail.product.id,
103
+ variantId: selectedVariant.id,
104
+ quantity,
105
+ });
106
+ setPending(false);
107
+ if (ok) setAdded(true);
108
+ }}
109
+ >
110
+ {pending ? "Adding..." : isAvailable ? "Add to cart" : "Sold out"}
111
+ </button>
112
+ <p aria-live="polite">{added ? "Added to cart." : null}</p>
113
+ {error ? <p role="alert">{error}</p> : null}
114
+ </section>
115
+ );
116
+ }
@@ -0,0 +1,50 @@
1
+ import Image from "next/image";
2
+ import Link from "next/link";
3
+ import { summarizeProductAvailability } from "@/lib/commerce/product-summary";
4
+ import type { ProductDetail } from "@/lib/commerce/types";
5
+
6
+ export function ProductCard({
7
+ detail,
8
+ priority = false,
9
+ }: {
10
+ detail: ProductDetail;
11
+ priority?: boolean;
12
+ }) {
13
+ const summary = summarizeProductAvailability(detail);
14
+ const image = detail.product.thumbnail ?? detail.product.images[0];
15
+ const variantLabel =
16
+ detail.variants.length === 1 ? "1 variant" : `${detail.variants.length} variants`;
17
+
18
+ const href = `/products/${detail.product.slug}`;
19
+
20
+ return (
21
+ <article>
22
+ {image ? (
23
+ <Link href={href} aria-label={`View ${detail.product.title}`}>
24
+ <Image
25
+ src={image.url}
26
+ alt={image.alt ?? detail.product.title}
27
+ width={900}
28
+ height={675}
29
+ priority={priority}
30
+ />
31
+ </Link>
32
+ ) : null}
33
+ <h2>
34
+ <Link href={href}>{detail.product.title}</Link>
35
+ </h2>
36
+ <p>{detail.product.description}</p>
37
+ <dl>
38
+ <dt>Status</dt>
39
+ <dd>{summary.available ? "Available" : summary.hasVariants ? "Sold out" : "Unavailable"}</dd>
40
+ <dt>Price</dt>
41
+ <dd>{summary.priceLabel}</dd>
42
+ <dt>Options</dt>
43
+ <dd>{variantLabel}</dd>
44
+ </dl>
45
+ <p>
46
+ <Link href={href}>Review product</Link>
47
+ </p>
48
+ </article>
49
+ );
50
+ }
@@ -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
+ }