@01.software/init 0.9.2 → 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 +0 -0
- package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
- package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
- package/dist/chunk-4LHYICUL.js.map +1 -0
- package/dist/{chunk-R4GGO33X.js → chunk-NJ4X7VNK.js} +1 -1
- package/dist/{chunk-R4GGO33X.js.map → chunk-NJ4X7VNK.js.map} +1 -1
- package/dist/chunk-Q6MSORYN.js +0 -0
- package/dist/chunk-STM4DKVZ.js +183 -0
- package/dist/chunk-STM4DKVZ.js.map +1 -0
- package/dist/{chunk-ENQSB4OF.js → chunk-WDWJ73KP.js} +40 -214
- package/dist/chunk-WDWJ73KP.js.map +1 -0
- package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
- package/dist/create-app-templates/ecommerce/CHANGELOG.md +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 +4 -3
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +40 -0
- package/dist/init.js +4 -3
- package/dist/templates.d.ts +27 -0
- package/dist/templates.js +1 -1
- package/package.json +31 -15
- package/dist/chunk-ENQSB4OF.js.map +0 -1
- package/dist/chunk-UA7WNT2F.js.map +0 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ecommerce
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [b011902]
|
|
8
|
+
- Updated dependencies [9d1b632]
|
|
9
|
+
- Updated dependencies [d28ad57]
|
|
10
|
+
- Updated dependencies [9cb8a41]
|
|
11
|
+
- Updated dependencies [a36d724]
|
|
12
|
+
- Updated dependencies [426c3ed]
|
|
13
|
+
- Updated dependencies [9261a15]
|
|
14
|
+
- Updated dependencies [8d8e700]
|
|
15
|
+
- Updated dependencies [9f3186c]
|
|
16
|
+
- Updated dependencies [a2fd60b]
|
|
17
|
+
- Updated dependencies [b1bdd49]
|
|
18
|
+
- Updated dependencies [e3b16a5]
|
|
19
|
+
- Updated dependencies [27a6502]
|
|
20
|
+
- Updated dependencies [5aa136f]
|
|
21
|
+
- Updated dependencies [87da012]
|
|
22
|
+
- Updated dependencies [3f261a6]
|
|
23
|
+
- Updated dependencies [17afb8e]
|
|
24
|
+
- Updated dependencies [a968dfd]
|
|
25
|
+
- Updated dependencies [4a13b7b]
|
|
26
|
+
- Updated dependencies [1d8ee45]
|
|
27
|
+
- Updated dependencies [923b0a2]
|
|
28
|
+
- Updated dependencies [2986732]
|
|
29
|
+
- Updated dependencies [8ded73e]
|
|
30
|
+
- @01.software/sdk@0.42.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Ecommerce Template
|
|
2
|
+
|
|
3
|
+
Minimal Next.js commerce storefront template with a pluggable payment-adapter
|
|
4
|
+
seam. A single source scaffolds either supported payment provider; the active
|
|
5
|
+
provider for a generated app is recorded in `app-config.ts` and its keys live in
|
|
6
|
+
`.env.local.example`.
|
|
7
|
+
|
|
8
|
+
## What is included
|
|
9
|
+
|
|
10
|
+
- App Router storefront routes for product listing, product detail, cart,
|
|
11
|
+
checkout, and payment success.
|
|
12
|
+
- Adapter boundaries under `lib/commerce` and `lib/payment`.
|
|
13
|
+
- Demo commerce and payment adapters backed by `data/mock-catalog.json`.
|
|
14
|
+
- Automatic `@01.software/sdk/server` commerce adapter when 01.software SDK
|
|
15
|
+
credentials are present.
|
|
16
|
+
- A payment-adapter registry (`lib/payment/provider.server.ts`) that selects a
|
|
17
|
+
provider from environment credentials, with first-class adapters for:
|
|
18
|
+
- **PortOne** — `@portone/browser-sdk/v2` on the client, `@portone/server-sdk`
|
|
19
|
+
on the server.
|
|
20
|
+
- **TossPayments** — the TossPayments browser SDK on the client, the
|
|
21
|
+
TossPayments payment REST API on the server.
|
|
22
|
+
- A theming seam: a CSS-variable token layer in `app/globals.css` plus Tailwind.
|
|
23
|
+
- Server-side order pricing, stock checks, and payment sync invariants.
|
|
24
|
+
- Local persisted cart state with Zustand.
|
|
25
|
+
|
|
26
|
+
## Scaffolding vs. this in-repo source
|
|
27
|
+
|
|
28
|
+
This directory is the single in-repo source and intentionally ships **both**
|
|
29
|
+
payment adapters so it builds and tests with every provider present.
|
|
30
|
+
`create-01-software-app` copies it, prunes the unused payment adapter (and its
|
|
31
|
+
dependency), generates `app-config.ts` and `.env.local.example` from
|
|
32
|
+
`templates/registry.json`, and pins the SDK version — producing a self-contained
|
|
33
|
+
single-provider app.
|
|
34
|
+
|
|
35
|
+
To add a new payment provider, see **Add a payment provider** below.
|
|
36
|
+
|
|
37
|
+
## Run locally
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pnpm install
|
|
41
|
+
pnpm dev
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Without SDK or payment credentials, the app uses demo providers:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# 01.software keys omitted -> demo catalog
|
|
48
|
+
# payment keys omitted -> local demo payment completion
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Mock orders are persisted to `.mock-orders.json` so redirect and success routes
|
|
52
|
+
can observe the same order during local development. This file is ignored and is
|
|
53
|
+
not a production store.
|
|
54
|
+
|
|
55
|
+
To use the Console ecommerce SDK adapter, set both SDK keys:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY=pk_...
|
|
59
|
+
SOFTWARE_SECRET_KEY=sk_...
|
|
60
|
+
SOFTWARE_API_URL=https://your-console-origin.example
|
|
61
|
+
SOFTWARE_SHIPPING_AMOUNT=3000
|
|
62
|
+
SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT=100000
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`SOFTWARE_API_URL`, `SOFTWARE_SHIPPING_AMOUNT`, and
|
|
66
|
+
`SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT` are optional. The SDK adapter resolves
|
|
67
|
+
payment return/webhook requests through the SDK `transactions` collection first,
|
|
68
|
+
then falls back to a local `.software-orders.json` development index. This file
|
|
69
|
+
is ignored and is not a production store.
|
|
70
|
+
|
|
71
|
+
## Payment provider keys
|
|
72
|
+
|
|
73
|
+
### PortOne
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
PORTONE_API_SECRET=...
|
|
77
|
+
NEXT_PUBLIC_PORTONE_STORE_ID=store_...
|
|
78
|
+
NEXT_PUBLIC_PORTONE_CHANNEL_KEY=channel-key-...
|
|
79
|
+
NEXT_PUBLIC_PORTONE_PAY_METHOD=CARD
|
|
80
|
+
PORTONE_WEBHOOK_SECRET=...
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`NEXT_PUBLIC_PORTONE_PAY_METHOD` is optional and defaults to `CARD`.
|
|
84
|
+
`PORTONE_WEBHOOK_SECRET` is required in production. For local development without
|
|
85
|
+
signed webhooks, set `PORTONE_ALLOW_UNSIGNED_WEBHOOKS=true`. When the secret is
|
|
86
|
+
present, the webhook route verifies PortOne webhook signatures through
|
|
87
|
+
`@portone/server-sdk`.
|
|
88
|
+
|
|
89
|
+
### TossPayments
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
TOSSPAYMENTS_SECRET_KEY=test_sk_...
|
|
93
|
+
NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY=test_ck_...
|
|
94
|
+
NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY=customer_...
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY` is optional. If omitted, the browser SDK
|
|
98
|
+
uses TossPayments' anonymous customer key. TossPayments redirects back with
|
|
99
|
+
`paymentKey`, `orderId`, and `amount`; the template validates the returned
|
|
100
|
+
amount against the stored order and confirms the payment on
|
|
101
|
+
`/api/checkout/payment-return` before showing the success page.
|
|
102
|
+
`TOSSPAYMENTS_API_BASE_URL` is optional and defaults to
|
|
103
|
+
`https://api.tosspayments.com/v1`.
|
|
104
|
+
|
|
105
|
+
## Add a payment provider
|
|
106
|
+
|
|
107
|
+
1. Add `lib/payment/adapters/<pg>.ts` implementing the `PaymentProvider` port
|
|
108
|
+
and exporting `create<Pg>Provider` plus `has<Pg>Credentials`.
|
|
109
|
+
2. Register it in `lib/payment/provider.server.ts` inside a matching pair of
|
|
110
|
+
`scaffold:provider:<id>` markers (import + registry entry).
|
|
111
|
+
3. Add a `ClientPaymentRequest` union case in `lib/payment/types.ts`.
|
|
112
|
+
4. Add a client branch (and its `scaffold:provider:<id>` markers) in
|
|
113
|
+
`components/checkout/checkout-form.tsx`.
|
|
114
|
+
5. Add the provider to `templates/registry.json` so the scaffolder offers it.
|
|
115
|
+
|
|
116
|
+
The template depends on the published `@01.software/sdk` package so it can be
|
|
117
|
+
copied out of this monorepo and installed as a standalone Next.js app.
|
|
118
|
+
|
|
119
|
+
The starter `software` product listing uses a simple products query followed by
|
|
120
|
+
per-product detail lookups so the UI can share one normalized product-detail
|
|
121
|
+
shape. It isolates individual detail failures with `Promise.allSettled`. For a
|
|
122
|
+
larger storefront, replace that listing path with the Console tenant's preferred
|
|
123
|
+
catalog/listing API shape.
|
|
124
|
+
|
|
125
|
+
## Verify
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pnpm test
|
|
129
|
+
pnpm check-types
|
|
130
|
+
pnpm lint
|
|
131
|
+
pnpm build
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Important boundary
|
|
135
|
+
|
|
136
|
+
The cart stores only `variantId` and `quantity`. Checkout sends those lines plus
|
|
137
|
+
customer and shipping details to the server; server code re-fetches variants,
|
|
138
|
+
checks stock, recomputes subtotal/shipping/total, creates a pending order, and
|
|
139
|
+
then synchronizes payment state.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { syncActiveCartCookieOnLogin } from "@/lib/cart/sync-on-login.server.ts";
|
|
2
|
+
import { login } from "@/lib/customer/auth-actions";
|
|
3
|
+
import { assertSameOriginJson } from "@/lib/customer/route-guard.ts";
|
|
4
|
+
import { authErrorResponse, parseLogin } from "@/lib/customer/route-helpers.ts";
|
|
5
|
+
import { setSessionToken } from "@/lib/customer/session";
|
|
6
|
+
|
|
7
|
+
// Log in: delegate to `client.customer.auth.login`, then stamp the customer JWT
|
|
8
|
+
// into the HttpOnly session cookie and slave the active-cart cookie to the
|
|
9
|
+
// customer's server-owned cart (union a pre-login guest cart, else load theirs).
|
|
10
|
+
// CSRF-guarded (same-origin + JSON).
|
|
11
|
+
export async function POST(request: Request) {
|
|
12
|
+
try {
|
|
13
|
+
assertSameOriginJson(request);
|
|
14
|
+
const input = parseLogin(await request.json());
|
|
15
|
+
const result = await login(input);
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
return Response.json(
|
|
18
|
+
{ code: "login_failed", message: result.message },
|
|
19
|
+
{ status: result.status },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (result.token) {
|
|
23
|
+
await setSessionToken(result.token);
|
|
24
|
+
await syncActiveCartCookieOnLogin(result.token);
|
|
25
|
+
}
|
|
26
|
+
return Response.json({ customer: result.customer });
|
|
27
|
+
} catch (error) {
|
|
28
|
+
return authErrorResponse(error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { assertSameOriginJson } from "@/lib/customer/route-guard.ts";
|
|
2
|
+
import { authErrorResponse } from "@/lib/customer/route-helpers.ts";
|
|
3
|
+
import { clearSessionAndCart } from "@/lib/customer/session";
|
|
4
|
+
|
|
5
|
+
// Log out: clear the HttpOnly session cookie AND the active-cart cookie together.
|
|
6
|
+
// The customer JWT is stateless, so dropping the cookie ends the session
|
|
7
|
+
// client-side; the cart-token handle is slaved to the session, so it is dropped
|
|
8
|
+
// atomically too (no later session inherits this identity's cart). CSRF-guarded
|
|
9
|
+
// (same-origin + JSON) so a cross-site request cannot force-logout the customer.
|
|
10
|
+
export async function POST(request: Request) {
|
|
11
|
+
try {
|
|
12
|
+
assertSameOriginJson(request);
|
|
13
|
+
await clearSessionAndCart();
|
|
14
|
+
return Response.json({ ok: true });
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return authErrorResponse(error);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { syncActiveCartCookieOnLogin } from "@/lib/cart/sync-on-login.server.ts";
|
|
2
|
+
import { login, register } from "@/lib/customer/auth-actions";
|
|
3
|
+
import { assertSameOriginJson } from "@/lib/customer/route-guard.ts";
|
|
4
|
+
import {
|
|
5
|
+
authErrorResponse,
|
|
6
|
+
parseRegister,
|
|
7
|
+
} from "@/lib/customer/route-helpers.ts";
|
|
8
|
+
import { setSessionToken } from "@/lib/customer/session";
|
|
9
|
+
|
|
10
|
+
// Register: delegate to `client.customer.auth.register`, then log the new
|
|
11
|
+
// customer in to mint a session JWT (register returns no token) and stamp it
|
|
12
|
+
// into the HttpOnly session cookie. CSRF-guarded (same-origin + JSON).
|
|
13
|
+
export async function POST(request: Request) {
|
|
14
|
+
try {
|
|
15
|
+
assertSameOriginJson(request);
|
|
16
|
+
const input = parseRegister(await request.json());
|
|
17
|
+
const created = await register(input);
|
|
18
|
+
if (!created.ok) {
|
|
19
|
+
return Response.json(
|
|
20
|
+
{ code: "register_failed", message: created.message },
|
|
21
|
+
{ status: created.status },
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Registration may require verification before login succeeds; if the
|
|
26
|
+
// follow-up login fails, the account still exists — report success without a
|
|
27
|
+
// session so the UI can route the customer to the login page.
|
|
28
|
+
const session = await login({
|
|
29
|
+
email: input.email,
|
|
30
|
+
password: input.password,
|
|
31
|
+
});
|
|
32
|
+
if (session.ok && session.token) {
|
|
33
|
+
await setSessionToken(session.token);
|
|
34
|
+
await syncActiveCartCookieOnLogin(session.token);
|
|
35
|
+
return Response.json({ customer: session.customer });
|
|
36
|
+
}
|
|
37
|
+
return Response.json({ customer: created.customer, requiresLogin: true });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return authErrorResponse(error);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { clearActiveCart } from "@/lib/cart/server-cart";
|
|
2
|
+
import { cartErrorResponse } from "@/lib/cart/route-helpers.ts";
|
|
3
|
+
|
|
4
|
+
// Empty the owned cart (no-op when none is owned).
|
|
5
|
+
export async function POST() {
|
|
6
|
+
try {
|
|
7
|
+
const cart = await clearActiveCart();
|
|
8
|
+
return Response.json({ cart });
|
|
9
|
+
} catch (error) {
|
|
10
|
+
return cartErrorResponse(error);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addItemToCart,
|
|
3
|
+
removeCartItemFromCart,
|
|
4
|
+
updateCartItemQuantity,
|
|
5
|
+
} from "@/lib/cart/server-cart";
|
|
6
|
+
import {
|
|
7
|
+
parseAddItem,
|
|
8
|
+
parseRemoveItem,
|
|
9
|
+
parseUpdateItem,
|
|
10
|
+
} from "@/lib/cart/parse-cart-request.ts";
|
|
11
|
+
import { cartErrorResponse } from "@/lib/cart/route-helpers.ts";
|
|
12
|
+
|
|
13
|
+
// Add a line. Creates + stamps the guest cart cookie when none is owned yet.
|
|
14
|
+
export async function POST(request: Request) {
|
|
15
|
+
try {
|
|
16
|
+
const cart = await addItemToCart(parseAddItem(await request.json()));
|
|
17
|
+
return Response.json({ cart });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return cartErrorResponse(error);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Update a line quantity by its cart-item handle.
|
|
24
|
+
export async function PATCH(request: Request) {
|
|
25
|
+
try {
|
|
26
|
+
const cart = await updateCartItemQuantity(
|
|
27
|
+
parseUpdateItem(await request.json()),
|
|
28
|
+
);
|
|
29
|
+
return Response.json({ cart });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return cartErrorResponse(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Remove a line by its cart-item handle.
|
|
36
|
+
export async function DELETE(request: Request) {
|
|
37
|
+
try {
|
|
38
|
+
const cart = await removeCartItemFromCart(
|
|
39
|
+
parseRemoveItem(await request.json()),
|
|
40
|
+
);
|
|
41
|
+
return Response.json({ cart });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return cartErrorResponse(error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { getCartFromCookie } from "@/lib/cart/server-cart";
|
|
2
|
+
import { cartErrorResponse } from "@/lib/cart/route-helpers.ts";
|
|
3
|
+
|
|
4
|
+
// The cart lives server-side (`commerce.cart.*`); this returns the current
|
|
5
|
+
// guest cart resolved from the HttpOnly ownership cookie, or `{ cart: null }`
|
|
6
|
+
// when none is owned yet.
|
|
7
|
+
export async function GET() {
|
|
8
|
+
try {
|
|
9
|
+
const cart = await getCartFromCookie();
|
|
10
|
+
return Response.json({ cart });
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return cartErrorResponse(error);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import {
|
|
3
|
+
AmountMismatchError,
|
|
4
|
+
assertAmountEquals,
|
|
5
|
+
} from "@/lib/payment/amount-gate";
|
|
6
|
+
import { getPaymentProvider } from "@/lib/payment/provider.server";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PG redirect return. For providers that require a server-side capture (e.g.
|
|
10
|
+
* Toss `/payments/confirm` with the returned `paymentKey`), this runs the shared
|
|
11
|
+
* amount-equality gate, captures, then hands off to the success page which
|
|
12
|
+
* reconciles the order via `syncOrderPayment` (server re-fetch →
|
|
13
|
+
* `orders.confirmPayment`).
|
|
14
|
+
*
|
|
15
|
+
* The redirect query is fully client-tamperable, so its `amount` is NEVER
|
|
16
|
+
* forwarded to the capture. We re-fetch the payment from the PG, assert the
|
|
17
|
+
* query amount (when present) equals the re-fetched amount (#1544 / I6 amount
|
|
18
|
+
* gate), and then capture with the PG-verified amount. The order is ultimately
|
|
19
|
+
* placed against the Console quote, never this query value.
|
|
20
|
+
*/
|
|
21
|
+
export async function GET(request: Request) {
|
|
22
|
+
const { searchParams } = new URL(request.url);
|
|
23
|
+
const paymentId =
|
|
24
|
+
searchParams.get("paymentId") ?? searchParams.get("orderId");
|
|
25
|
+
const orderNumber = searchParams.get("orderNumber") ?? undefined;
|
|
26
|
+
const providerPaymentId = searchParams.get("paymentKey") ?? undefined;
|
|
27
|
+
const amountValue = searchParams.get("amount");
|
|
28
|
+
const clientAmount = amountValue ? Number(amountValue) : undefined;
|
|
29
|
+
|
|
30
|
+
if (!paymentId) {
|
|
31
|
+
return Response.json({ code: "missing_payment_id" }, { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
if (amountValue && !Number.isFinite(clientAmount)) {
|
|
34
|
+
return Response.json({ code: "invalid_amount" }, { status: 400 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Capture is required only when the provider returns a capture token
|
|
38
|
+
// (`paymentKey`) or a client amount to validate. Providers without a
|
|
39
|
+
// return-side capture (PortOne, mock) fall straight through to the success
|
|
40
|
+
// page, where `syncOrderPayment` re-fetches, gates, and places.
|
|
41
|
+
if (providerPaymentId || clientAmount != null) {
|
|
42
|
+
try {
|
|
43
|
+
const provider = getPaymentProvider();
|
|
44
|
+
// Re-fetch the authoritative amount from the PG — the trust anchor.
|
|
45
|
+
const verified = await provider.getPayment(paymentId);
|
|
46
|
+
if (!verified) {
|
|
47
|
+
return Response.json(
|
|
48
|
+
{ code: "payment_not_found" },
|
|
49
|
+
{ status: 404 },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
// Amount gate: a tampered redirect query is rejected before any capture.
|
|
53
|
+
if (clientAmount != null) {
|
|
54
|
+
assertAmountEquals({
|
|
55
|
+
context: `return:${provider.provider}:${paymentId}`,
|
|
56
|
+
expected: verified.amount,
|
|
57
|
+
actual: clientAmount,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// Capture with the PG-verified amount, never the client query value.
|
|
61
|
+
await provider.confirmPayment({
|
|
62
|
+
paymentId,
|
|
63
|
+
providerPaymentId,
|
|
64
|
+
amount: verified.amount,
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error instanceof AmountMismatchError) {
|
|
68
|
+
return Response.json(
|
|
69
|
+
{ code: "amount_mismatch" },
|
|
70
|
+
{ status: 422 },
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const message =
|
|
74
|
+
error instanceof Error ? error.message : "Payment confirmation failed";
|
|
75
|
+
return Response.json(
|
|
76
|
+
{ code: "payment_confirmation_failed", message },
|
|
77
|
+
{ status: 422 },
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const params = new URLSearchParams({ paymentId });
|
|
83
|
+
if (orderNumber) params.set("orderNumber", orderNumber);
|
|
84
|
+
|
|
85
|
+
redirect(`/checkout/success?${params.toString()}`);
|
|
86
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getCommerceProvider } from "@/lib/commerce/provider.server";
|
|
2
|
+
import { AmountMismatchError } from "@/lib/payment/amount-gate";
|
|
3
|
+
import { getPaymentProvider } from "@/lib/payment/provider.server";
|
|
4
|
+
import { syncOrderPayment } from "@/lib/payment/sync-order-payment.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Order reconciliation, moved off the success-page RSC render into a route
|
|
8
|
+
* handler (#1544 / I6). Mutating during Server Component render is unsound
|
|
9
|
+
* (renders may run more than once); the success page now renders a client
|
|
10
|
+
* trigger that POSTs here exactly once. `syncOrderPayment` is itself idempotent
|
|
11
|
+
* — the Console dedups by `pgPaymentId` / `providerEventId` — so a retry or a
|
|
12
|
+
* concurrent webhook still collapses to a single `paid` transition. The shared
|
|
13
|
+
* amount-equality gate runs inside `syncOrderPayment` on this success path.
|
|
14
|
+
*/
|
|
15
|
+
export async function POST(request: Request) {
|
|
16
|
+
let body: { paymentId?: unknown; orderNumber?: unknown };
|
|
17
|
+
try {
|
|
18
|
+
body = (await request.json()) as typeof body;
|
|
19
|
+
} catch {
|
|
20
|
+
return Response.json({ code: "invalid_request" }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const paymentId =
|
|
24
|
+
typeof body.paymentId === "string" ? body.paymentId : undefined;
|
|
25
|
+
const orderNumber =
|
|
26
|
+
typeof body.orderNumber === "string" ? body.orderNumber : undefined;
|
|
27
|
+
if (!paymentId) {
|
|
28
|
+
return Response.json({ code: "missing_payment_id" }, { status: 400 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const result = await syncOrderPayment({
|
|
33
|
+
paymentId,
|
|
34
|
+
orderNumber,
|
|
35
|
+
commerceProvider: getCommerceProvider(),
|
|
36
|
+
paymentProvider: getPaymentProvider(),
|
|
37
|
+
});
|
|
38
|
+
return Response.json(result);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof AmountMismatchError) {
|
|
41
|
+
return Response.json({ code: "amount_mismatch" }, { status: 422 });
|
|
42
|
+
}
|
|
43
|
+
const message =
|
|
44
|
+
error instanceof Error ? error.message : "Reconciliation failed";
|
|
45
|
+
return Response.json(
|
|
46
|
+
{ code: "reconciliation_failed", message },
|
|
47
|
+
{ status: 422 },
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getCheckoutCommerceProvider } from "@/lib/checkout/checkout-provider";
|
|
2
|
+
import {
|
|
3
|
+
getCheckoutErrorMessage,
|
|
4
|
+
getCheckoutErrorStatus,
|
|
5
|
+
} from "@/lib/checkout/checkout-errors";
|
|
6
|
+
import { startCheckout } from "@/lib/checkout/start-checkout.ts";
|
|
7
|
+
import {
|
|
8
|
+
dropCartCookie,
|
|
9
|
+
getCheckoutCartToken,
|
|
10
|
+
} from "@/lib/cart/server-cart";
|
|
11
|
+
import { getPaymentProvider } from "@/lib/payment/provider.server";
|
|
12
|
+
|
|
13
|
+
export async function POST(request: Request) {
|
|
14
|
+
const cartToken = await getCheckoutCartToken();
|
|
15
|
+
if (!cartToken) {
|
|
16
|
+
return Response.json(
|
|
17
|
+
{ code: "cart_not_found", message: "No active cart to check out" },
|
|
18
|
+
{ status: 409 },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await startCheckout({
|
|
24
|
+
cartToken,
|
|
25
|
+
payload: await request.json(),
|
|
26
|
+
commerceProvider: await getCheckoutCommerceProvider(),
|
|
27
|
+
paymentProvider: getPaymentProvider(),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// Checkout consumed the cart (Shopify cart→checkout boundary). Drop the
|
|
31
|
+
// ownership cookie so the next visit starts a fresh cart.
|
|
32
|
+
await dropCartCookie();
|
|
33
|
+
|
|
34
|
+
return Response.json(result);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = getCheckoutErrorMessage(error);
|
|
37
|
+
const status = getCheckoutErrorStatus(error);
|
|
38
|
+
|
|
39
|
+
return Response.json({ code: "checkout_failed", message }, { status });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PageShell } from "@/components/layout/page-shell";
|
|
2
|
+
import { CheckoutReconcile } from "@/components/checkout/checkout-reconcile";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Payment return landing. Reconciliation is intentionally NOT performed during
|
|
6
|
+
* render — a Server Component render must be side-effect free and may run more
|
|
7
|
+
* than once. The client `CheckoutReconcile` trigger POSTs to the reconcile
|
|
8
|
+
* route handler once on mount instead (#1544 / I6).
|
|
9
|
+
*/
|
|
10
|
+
export default async function CheckoutSuccessPage({
|
|
11
|
+
searchParams,
|
|
12
|
+
}: {
|
|
13
|
+
searchParams: Promise<{ paymentId?: string; orderNumber?: string }>;
|
|
14
|
+
}) {
|
|
15
|
+
const params = await searchParams;
|
|
16
|
+
const paymentId = params.paymentId;
|
|
17
|
+
|
|
18
|
+
if (!paymentId) {
|
|
19
|
+
return (
|
|
20
|
+
<PageShell>
|
|
21
|
+
<section>
|
|
22
|
+
<h1>Missing payment</h1>
|
|
23
|
+
<p>The return URL did not include a payment ID.</p>
|
|
24
|
+
</section>
|
|
25
|
+
</PageShell>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<PageShell>
|
|
31
|
+
<CheckoutReconcile paymentId={paymentId} orderNumber={params.orderNumber} />
|
|
32
|
+
</PageShell>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Theming seam.
|
|
5
|
+
*
|
|
6
|
+
* The CSS-variable token layer below is the rebrand surface: change a value in
|
|
7
|
+
* `:root` and every page that consumes the token re-skins. The `@theme inline`
|
|
8
|
+
* block exposes the same tokens to Tailwind utilities (e.g. `bg-background`,
|
|
9
|
+
* `text-brand`) so component styling can be added without leaving this layer.
|
|
10
|
+
* The storefront keeps its semantic-HTML / a11y baseline; styling is additive.
|
|
11
|
+
*/
|
|
12
|
+
@theme inline {
|
|
13
|
+
--color-background: var(--background);
|
|
14
|
+
--color-foreground: var(--foreground);
|
|
15
|
+
--color-muted: var(--muted);
|
|
16
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
17
|
+
--color-border: var(--border);
|
|
18
|
+
--color-brand: var(--brand);
|
|
19
|
+
--color-brand-foreground: var(--brand-foreground);
|
|
20
|
+
--font-sans: var(--font-sans);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
:root {
|
|
24
|
+
/* Brand */
|
|
25
|
+
--brand: #1f2937;
|
|
26
|
+
--brand-foreground: #ffffff;
|
|
27
|
+
|
|
28
|
+
/* Surfaces */
|
|
29
|
+
--background: #ffffff;
|
|
30
|
+
--foreground: #111827;
|
|
31
|
+
--muted: #f3f4f6;
|
|
32
|
+
--muted-foreground: #6b7280;
|
|
33
|
+
--border: rgba(17, 24, 39, 0.12);
|
|
34
|
+
|
|
35
|
+
/* Type */
|
|
36
|
+
--font-sans:
|
|
37
|
+
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
|
38
|
+
Arial, sans-serif;
|
|
39
|
+
|
|
40
|
+
/* Spacing rhythm shared by page sections */
|
|
41
|
+
--space-page: 1.5rem;
|
|
42
|
+
--measure: 72ch;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@media (prefers-color-scheme: dark) {
|
|
46
|
+
:root {
|
|
47
|
+
--brand: #e5e7eb;
|
|
48
|
+
--brand-foreground: #111827;
|
|
49
|
+
--background: #0b0f19;
|
|
50
|
+
--foreground: #f3f4f6;
|
|
51
|
+
--muted: #161b27;
|
|
52
|
+
--muted-foreground: #9ca3af;
|
|
53
|
+
--border: rgba(243, 244, 246, 0.14);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
body {
|
|
58
|
+
background: var(--background);
|
|
59
|
+
color: var(--foreground);
|
|
60
|
+
font-family: var(--font-sans);
|
|
61
|
+
line-height: 1.5;
|
|
62
|
+
-webkit-font-smoothing: antialiased;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
main {
|
|
66
|
+
padding: var(--space-page);
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { appConfig } from "@/app-config";
|
|
3
|
+
import { CartProvider } from "@/lib/cart/use-cart";
|
|
4
|
+
import "./globals.css";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: appConfig.title,
|
|
8
|
+
description: "A minimal adapter-based commerce storefront template.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export default function RootLayout({
|
|
12
|
+
children,
|
|
13
|
+
}: Readonly<{
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}>) {
|
|
16
|
+
return (
|
|
17
|
+
<html lang="en">
|
|
18
|
+
<body>
|
|
19
|
+
<CartProvider>{children}</CartProvider>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
|
22
|
+
);
|
|
23
|
+
}
|