@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.
- package/dist/ai-docs.d.ts +13 -0
- package/dist/ai-docs.js +1 -1
- 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-TBGKXE3Q.js → chunk-NJ4X7VNK.js} +5 -5
- package/dist/chunk-NJ4X7VNK.js.map +1 -0
- package/dist/{chunk-5K2CB2Y5.js → chunk-Q6MSORYN.js} +14 -37
- package/dist/chunk-Q6MSORYN.js.map +1 -0
- package/dist/chunk-STM4DKVZ.js +183 -0
- package/dist/chunk-STM4DKVZ.js.map +1 -0
- package/dist/{chunk-2IGKOSK7.js → chunk-WDWJ73KP.js} +41 -215
- 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 +30 -0
- package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
- package/dist/create-app-templates/ecommerce/README.md +139 -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 +119 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +267 -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 +96 -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 +76 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +63 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +3 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +336 -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 +913 -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 +206 -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 +392 -0
- package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1537 -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 +6 -5
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +40 -0
- package/dist/init.js +5 -4
- package/dist/templates.d.ts +27 -0
- package/dist/templates.js +1 -1
- package/package.json +31 -15
- package/dist/chunk-2IGKOSK7.js.map +0 -1
- package/dist/chunk-5K2CB2Y5.js.map +0 -1
- package/dist/chunk-TBGKXE3Q.js.map +0 -1
- 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,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
|
+
}
|