@01.software/init 0.9.2 → 0.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-docs.d.ts +13 -0
- package/dist/browser-auth-CJDrpp5T.d.ts +11 -0
- package/dist/{chunk-UA7WNT2F.js → chunk-4LHYICUL.js} +1 -1
- package/dist/chunk-4LHYICUL.js.map +1 -0
- package/dist/{chunk-R4GGO33X.js → chunk-NJ4X7VNK.js} +1 -1
- package/dist/{chunk-R4GGO33X.js.map → chunk-NJ4X7VNK.js.map} +1 -1
- package/dist/chunk-STM4DKVZ.js +183 -0
- package/dist/chunk-STM4DKVZ.js.map +1 -0
- package/dist/{chunk-ENQSB4OF.js → chunk-WDWJ73KP.js} +40 -214
- package/dist/chunk-WDWJ73KP.js.map +1 -0
- package/dist/create-app-templates/ecommerce/AGENTS.md +88 -0
- package/dist/create-app-templates/ecommerce/CHANGELOG.md +48 -0
- package/dist/create-app-templates/ecommerce/CLAUDE.md +1 -0
- package/dist/create-app-templates/ecommerce/README.md +154 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/login/route.ts +30 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/logout/route.ts +18 -0
- package/dist/create-app-templates/ecommerce/app/api/auth/register/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/clear/route.ts +12 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/items/route.ts +45 -0
- package/dist/create-app-templates/ecommerce/app/api/cart/route.ts +14 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/payment-return/route.ts +86 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/reconcile/route.ts +50 -0
- package/dist/create-app-templates/ecommerce/app/api/checkout/route.ts +41 -0
- package/dist/create-app-templates/ecommerce/app/cart/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/page.tsx +10 -0
- package/dist/create-app-templates/ecommerce/app/checkout/success/page.tsx +34 -0
- package/dist/create-app-templates/ecommerce/app/favicon.ico +0 -0
- package/dist/create-app-templates/ecommerce/app/globals.css +67 -0
- package/dist/create-app-templates/ecommerce/app/layout.tsx +23 -0
- package/dist/create-app-templates/ecommerce/app/login/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/page.tsx +5 -0
- package/dist/create-app-templates/ecommerce/app/products/[slug]/page.tsx +46 -0
- package/dist/create-app-templates/ecommerce/app/products/page.tsx +45 -0
- package/dist/create-app-templates/ecommerce/app/register/page.tsx +11 -0
- package/dist/create-app-templates/ecommerce/app/webhook/payment/route.ts +20 -0
- package/dist/create-app-templates/ecommerce/app-config.ts +54 -0
- package/dist/create-app-templates/ecommerce/components/auth/auth-form.tsx +109 -0
- package/dist/create-app-templates/ecommerce/components/cart/cart-content.tsx +129 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-form.tsx +307 -0
- package/dist/create-app-templates/ecommerce/components/checkout/checkout-reconcile.tsx +78 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-nav.tsx +48 -0
- package/dist/create-app-templates/ecommerce/components/layout/account-slot.tsx +12 -0
- package/dist/create-app-templates/ecommerce/components/layout/cart-link.tsx +13 -0
- package/dist/create-app-templates/ecommerce/components/layout/page-shell.tsx +11 -0
- package/dist/create-app-templates/ecommerce/components/layout/site-header.tsx +22 -0
- package/dist/create-app-templates/ecommerce/components/product/add-to-cart.tsx +116 -0
- package/dist/create-app-templates/ecommerce/components/product/product-card.tsx +50 -0
- package/dist/create-app-templates/ecommerce/components/product/product-gallery.tsx +39 -0
- package/dist/create-app-templates/ecommerce/data/mock-catalog.json +173 -0
- package/dist/create-app-templates/ecommerce/eslint.config.mjs +18 -0
- package/dist/create-app-templates/ecommerce/lib/cart/cookie.ts +40 -0
- package/dist/create-app-templates/ecommerce/lib/cart/normalize.ts +32 -0
- package/dist/create-app-templates/ecommerce/lib/cart/parse-cart-request.ts +56 -0
- package/dist/create-app-templates/ecommerce/lib/cart/route-helpers.ts +17 -0
- package/dist/create-app-templates/ecommerce/lib/cart/select-provider.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/cart/server-cart.ts +135 -0
- package/dist/create-app-templates/ecommerce/lib/cart/sync-on-login.server.ts +34 -0
- package/dist/create-app-templates/ecommerce/lib/cart/use-cart.tsx +151 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-errors.ts +22 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/checkout-provider.ts +28 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/parse-checkout-payload.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/start-checkout.ts +73 -0
- package/dist/create-app-templates/ecommerce/lib/checkout/types.ts +6 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/mock.ts +346 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software-mappers.ts +312 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/adapters/software.ts +930 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/product-summary.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.server.ts +60 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/provider.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/stock.ts +37 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/types.ts +208 -0
- package/dist/create-app-templates/ecommerce/lib/commerce/variant-selection.ts +23 -0
- package/dist/create-app-templates/ecommerce/lib/customer/auth-actions.ts +131 -0
- package/dist/create-app-templates/ecommerce/lib/customer/cart-sync.ts +44 -0
- package/dist/create-app-templates/ecommerce/lib/customer/client.server.ts +109 -0
- package/dist/create-app-templates/ecommerce/lib/customer/current-customer.ts +15 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-guard.ts +58 -0
- package/dist/create-app-templates/ecommerce/lib/customer/route-helpers.ts +75 -0
- package/dist/create-app-templates/ecommerce/lib/customer/session.ts +108 -0
- package/dist/create-app-templates/ecommerce/lib/format.ts +7 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/mock.ts +84 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/portone.ts +254 -0
- package/dist/create-app-templates/ecommerce/lib/payment/adapters/tosspayments.ts +287 -0
- package/dist/create-app-templates/ecommerce/lib/payment/amount-gate.ts +86 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.server.ts +51 -0
- package/dist/create-app-templates/ecommerce/lib/payment/provider.ts +18 -0
- package/dist/create-app-templates/ecommerce/lib/payment/sync-order-payment.ts +96 -0
- package/dist/create-app-templates/ecommerce/lib/payment/types.ts +71 -0
- package/dist/create-app-templates/ecommerce/lib/server-only-guard.ts +20 -0
- package/dist/create-app-templates/ecommerce/next-env.d.ts +6 -0
- package/dist/create-app-templates/ecommerce/next.config.ts +16 -0
- package/dist/create-app-templates/ecommerce/package.json +33 -0
- package/dist/create-app-templates/ecommerce/postcss.config.mjs +7 -0
- package/dist/create-app-templates/ecommerce/tests/customer-auth.test.ts +263 -0
- package/dist/create-app-templates/ecommerce/tests/customer-cart.test.ts +401 -0
- package/dist/create-app-templates/ecommerce/tests/domain.test.ts +1632 -0
- package/dist/create-app-templates/ecommerce/tsconfig.json +35 -0
- package/dist/create-app-templates/registry.json +66 -0
- package/dist/create-app.d.ts +40 -0
- package/dist/create-app.js +652 -0
- package/dist/create-app.js.map +1 -0
- package/dist/detect-Bjxp9wcS.d.ts +13 -0
- package/dist/file-ops.d.ts +21 -0
- package/dist/file-ops.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +40 -0
- package/dist/init.js +4 -3
- package/dist/templates.d.ts +27 -0
- package/dist/templates.js +1 -1
- package/package.json +18 -3
- package/dist/chunk-ENQSB4OF.js.map +0 -1
- package/dist/chunk-UA7WNT2F.js.map +0 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# ecommerce
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [81ef105]
|
|
8
|
+
- Updated dependencies [9789528]
|
|
9
|
+
- Updated dependencies [9b7afab]
|
|
10
|
+
- Updated dependencies [db8bd4b]
|
|
11
|
+
- Updated dependencies [b95f6ac]
|
|
12
|
+
- Updated dependencies [c60ec7f]
|
|
13
|
+
- Updated dependencies [6c71a71]
|
|
14
|
+
- Updated dependencies [3aa6239]
|
|
15
|
+
- Updated dependencies [5d7b26f]
|
|
16
|
+
- Updated dependencies [294b7d1]
|
|
17
|
+
- Updated dependencies [f87fe09]
|
|
18
|
+
- Updated dependencies [4362b14]
|
|
19
|
+
- @01.software/sdk@0.43.0
|
|
20
|
+
|
|
21
|
+
## 0.1.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- Updated dependencies [b011902]
|
|
26
|
+
- Updated dependencies [9d1b632]
|
|
27
|
+
- Updated dependencies [d28ad57]
|
|
28
|
+
- Updated dependencies [9cb8a41]
|
|
29
|
+
- Updated dependencies [a36d724]
|
|
30
|
+
- Updated dependencies [426c3ed]
|
|
31
|
+
- Updated dependencies [9261a15]
|
|
32
|
+
- Updated dependencies [8d8e700]
|
|
33
|
+
- Updated dependencies [9f3186c]
|
|
34
|
+
- Updated dependencies [a2fd60b]
|
|
35
|
+
- Updated dependencies [b1bdd49]
|
|
36
|
+
- Updated dependencies [e3b16a5]
|
|
37
|
+
- Updated dependencies [27a6502]
|
|
38
|
+
- Updated dependencies [5aa136f]
|
|
39
|
+
- Updated dependencies [87da012]
|
|
40
|
+
- Updated dependencies [3f261a6]
|
|
41
|
+
- Updated dependencies [17afb8e]
|
|
42
|
+
- Updated dependencies [a968dfd]
|
|
43
|
+
- Updated dependencies [4a13b7b]
|
|
44
|
+
- Updated dependencies [1d8ee45]
|
|
45
|
+
- Updated dependencies [923b0a2]
|
|
46
|
+
- Updated dependencies [2986732]
|
|
47
|
+
- Updated dependencies [8ded73e]
|
|
48
|
+
- @01.software/sdk@0.42.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
- Server-authoritative cart operations through the template's own route handlers,
|
|
25
|
+
backed by `commerce.cart.*`.
|
|
26
|
+
- An HttpOnly cart cookie containing only the unguessable `cartToken`; client
|
|
27
|
+
JavaScript receives rendered cart views but never owns the authoritative cart
|
|
28
|
+
or token, and no cart contents are persisted in localStorage.
|
|
29
|
+
- Checkout through `orders.checkout({ cartId })`, with orders resolved after
|
|
30
|
+
payment by `orders.getByPaymentId`.
|
|
31
|
+
|
|
32
|
+
## Scaffolding vs. this in-repo source
|
|
33
|
+
|
|
34
|
+
This directory is the single in-repo source and intentionally ships **both**
|
|
35
|
+
payment adapters so it builds and tests with every provider present.
|
|
36
|
+
`create-01-software-app` copies it, prunes the unused payment adapter (and its
|
|
37
|
+
dependency), generates `app-config.ts` and `.env.local.example` from
|
|
38
|
+
`templates/registry.json`, and pins the SDK version — producing a self-contained
|
|
39
|
+
single-provider app.
|
|
40
|
+
|
|
41
|
+
To add a new payment provider, see **Add a payment provider** below.
|
|
42
|
+
|
|
43
|
+
## Run locally
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pnpm install
|
|
47
|
+
pnpm dev
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Without SDK or payment credentials, the app uses demo providers:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 01.software keys omitted -> demo catalog plus in-memory cart/checkout
|
|
54
|
+
# payment keys omitted -> local demo payment completion
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The mock adapter emulates the server cart and checkout flow in memory for
|
|
58
|
+
zero-backend demos. Redirect and success routes resolve the same in-process
|
|
59
|
+
checkout model without writing a local order index.
|
|
60
|
+
|
|
61
|
+
To use the Console ecommerce SDK adapter, set both SDK keys:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY=pk_...
|
|
65
|
+
SOFTWARE_SECRET_KEY=sk_...
|
|
66
|
+
SOFTWARE_API_URL=https://your-console-origin.example
|
|
67
|
+
SOFTWARE_SHIPPING_AMOUNT=3000
|
|
68
|
+
SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT=100000
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`SOFTWARE_API_URL`, `SOFTWARE_SHIPPING_AMOUNT`, and
|
|
72
|
+
`SOFTWARE_FREE_SHIPPING_ABOVE_AMOUNT` are optional. The SDK adapter uses Console
|
|
73
|
+
cart and order APIs for the full server-authoritative flow: route handlers call
|
|
74
|
+
`commerce.cart.*`, checkout calls `orders.checkout({ cartId })`, and
|
|
75
|
+
return/webhook/success reconciliation resolves orders by payment id through
|
|
76
|
+
`orders.getByPaymentId`.
|
|
77
|
+
|
|
78
|
+
## Payment provider keys
|
|
79
|
+
|
|
80
|
+
### PortOne
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
PORTONE_API_SECRET=...
|
|
84
|
+
NEXT_PUBLIC_PORTONE_STORE_ID=store_...
|
|
85
|
+
NEXT_PUBLIC_PORTONE_CHANNEL_KEY=channel-key-...
|
|
86
|
+
NEXT_PUBLIC_PORTONE_PAY_METHOD=CARD
|
|
87
|
+
PORTONE_WEBHOOK_SECRET=...
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`NEXT_PUBLIC_PORTONE_PAY_METHOD` is optional and defaults to `CARD`.
|
|
91
|
+
`PORTONE_WEBHOOK_SECRET` is required in production. For local development without
|
|
92
|
+
signed webhooks, set `PORTONE_ALLOW_UNSIGNED_WEBHOOKS=true`. When the secret is
|
|
93
|
+
present, the webhook route verifies PortOne webhook signatures through
|
|
94
|
+
`@portone/server-sdk`.
|
|
95
|
+
|
|
96
|
+
### TossPayments
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
TOSSPAYMENTS_SECRET_KEY=test_sk_...
|
|
100
|
+
NEXT_PUBLIC_TOSSPAYMENTS_CLIENT_KEY=test_ck_...
|
|
101
|
+
NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY=customer_...
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`NEXT_PUBLIC_TOSSPAYMENTS_CUSTOMER_KEY` is optional. If omitted, the browser SDK
|
|
105
|
+
uses TossPayments' anonymous customer key. TossPayments redirects back with
|
|
106
|
+
`paymentKey`, `orderId`, and `amount`; `/api/checkout/payment-return` re-fetches
|
|
107
|
+
the provider payment, rejects a tampered redirect amount, captures with the
|
|
108
|
+
PG-verified amount, then redirects to the success page. Success-page
|
|
109
|
+
reconciliation re-fetches the provider payment again and places the Console
|
|
110
|
+
checkout through `orders.confirmPayment`, where the open checkout quote remains
|
|
111
|
+
the authoritative amount check.
|
|
112
|
+
`TOSSPAYMENTS_API_BASE_URL` is optional and defaults to
|
|
113
|
+
`https://api.tosspayments.com/v1`.
|
|
114
|
+
|
|
115
|
+
## Add a payment provider
|
|
116
|
+
|
|
117
|
+
1. Add `lib/payment/adapters/<pg>.ts` implementing the `PaymentProvider` port
|
|
118
|
+
and exporting `create<Pg>Provider` plus `has<Pg>Credentials`.
|
|
119
|
+
2. Register it in `lib/payment/provider.server.ts` inside a matching pair of
|
|
120
|
+
`scaffold:provider:<id>` markers (import + registry entry).
|
|
121
|
+
3. Add a `ClientPaymentRequest` union case in `lib/payment/types.ts`.
|
|
122
|
+
4. Add a client branch (and its `scaffold:provider:<id>` markers) in
|
|
123
|
+
`components/checkout/checkout-form.tsx`.
|
|
124
|
+
5. Add the provider to `templates/registry.json` so the scaffolder offers it.
|
|
125
|
+
|
|
126
|
+
The template depends on the published `@01.software/sdk` package so it can be
|
|
127
|
+
copied out of this monorepo and installed as a standalone Next.js app.
|
|
128
|
+
|
|
129
|
+
The starter `software` product listing uses a simple products query followed by
|
|
130
|
+
per-product detail lookups so the UI can share one normalized product-detail
|
|
131
|
+
shape. It isolates individual detail failures with `Promise.allSettled`. For a
|
|
132
|
+
larger storefront, replace that listing path with the Console tenant's preferred
|
|
133
|
+
catalog/listing API shape.
|
|
134
|
+
|
|
135
|
+
## Verify
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
pnpm test
|
|
139
|
+
pnpm check-types
|
|
140
|
+
pnpm lint
|
|
141
|
+
pnpm build
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Important boundary
|
|
145
|
+
|
|
146
|
+
The browser never owns the authoritative cart or cart capability tokens. It
|
|
147
|
+
talks to the template's cart route handlers, which keep the cart
|
|
148
|
+
server-authoritative and store only an HttpOnly cart cookie in the browser.
|
|
149
|
+
Client components may keep rendered cart views in memory, but cart mutations and
|
|
150
|
+
checkout always return to the server cart. Checkout attaches customer and
|
|
151
|
+
shipping details to that server cart, converts it with
|
|
152
|
+
`orders.checkout({ cartId })`, and then synchronizes payment state. After
|
|
153
|
+
payment, order lookup uses `orders.getByPaymentId`, so the template does not
|
|
154
|
+
need a file-backed order index.
|
|
@@ -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
|
+
}
|