@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,37 @@
|
|
|
1
|
+
import { formatMoney } from "../format.ts";
|
|
2
|
+
import type { ProductDetail } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export type ProductAvailabilitySummary = {
|
|
5
|
+
hasVariants: boolean;
|
|
6
|
+
available: boolean;
|
|
7
|
+
priceLabel: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function summarizeProductAvailability(
|
|
11
|
+
detail: ProductDetail,
|
|
12
|
+
): ProductAvailabilitySummary {
|
|
13
|
+
const prices = detail.variants.map((variant) => variant.price);
|
|
14
|
+
const available = detail.variants.some(
|
|
15
|
+
(variant) => variant.isUnlimited || variant.stock - variant.reservedStock > 0,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
if (prices.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
hasVariants: false,
|
|
21
|
+
available: false,
|
|
22
|
+
priceLabel: "Unavailable",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const minPrice = Math.min(...prices);
|
|
27
|
+
const maxPrice = Math.max(...prices);
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
hasVariants: true,
|
|
31
|
+
available,
|
|
32
|
+
priceLabel:
|
|
33
|
+
minPrice === maxPrice
|
|
34
|
+
? formatMoney(minPrice)
|
|
35
|
+
: `${formatMoney(minPrice)} - ${formatMoney(maxPrice)}`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import "../server-only-guard.ts";
|
|
2
|
+
import { createMockCommerceProvider } from "./adapters/mock.ts";
|
|
3
|
+
import {
|
|
4
|
+
createSoftwareCommerceProvider,
|
|
5
|
+
createSoftwareCustomerCartProvider,
|
|
6
|
+
createSoftwareCustomerCheckoutProvider,
|
|
7
|
+
} from "./adapters/software.ts";
|
|
8
|
+
import type { CartProvider, CommerceProvider } from "./provider.ts";
|
|
9
|
+
import type { CheckoutCommerceProvider } from "../checkout/types.ts";
|
|
10
|
+
|
|
11
|
+
export function getCommerceProvider(): CommerceProvider {
|
|
12
|
+
if (hasSoftwareCredentials(process.env)) {
|
|
13
|
+
return createSoftwareCommerceProvider();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return createMockCommerceProvider();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the cart provider for a logged-in customer: cart reads/writes go
|
|
21
|
+
* through a customer-JWT-scoped client so they operate the customer's own,
|
|
22
|
+
* device-portable cart.
|
|
23
|
+
*
|
|
24
|
+
* Gated on the same full software credentials as the guest provider (publishable
|
|
25
|
+
* **and** secret): `createSoftwareCustomerCartProvider` reuses
|
|
26
|
+
* `getSoftwareCommerceConfig`, which needs the secret key for the shipping policy
|
|
27
|
+
* even though the customer client itself uses only the publishable key + JWT. The
|
|
28
|
+
* supported software config sets both keys; in the unsupported half-config
|
|
29
|
+
* (publishable only) this degrades to the in-memory mock just like the guest
|
|
30
|
+
* provider, rather than throwing.
|
|
31
|
+
*/
|
|
32
|
+
export function getCustomerCartProvider(token: string): CartProvider {
|
|
33
|
+
if (hasSoftwareCredentials(process.env)) {
|
|
34
|
+
return createSoftwareCustomerCartProvider(token);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return createMockCommerceProvider();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve checkout for a logged-in customer through the customer-JWT client
|
|
42
|
+
* (publishable key + JWT, no secret key) so Console checkout runs under the
|
|
43
|
+
* customer identity and enforces cart ownership.
|
|
44
|
+
*/
|
|
45
|
+
export function getCustomerCheckoutProvider(
|
|
46
|
+
token: string,
|
|
47
|
+
): CheckoutCommerceProvider {
|
|
48
|
+
if (hasSoftwareCredentials(process.env)) {
|
|
49
|
+
return createSoftwareCustomerCheckoutProvider(token);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return createMockCommerceProvider();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hasSoftwareCredentials(env: NodeJS.ProcessEnv): boolean {
|
|
56
|
+
return Boolean(
|
|
57
|
+
env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY?.trim() &&
|
|
58
|
+
env.SOFTWARE_SECRET_KEY?.trim(),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CartItemRef,
|
|
3
|
+
CartLine,
|
|
4
|
+
CartView,
|
|
5
|
+
CheckoutCartInput,
|
|
6
|
+
CreatePendingOrderResult,
|
|
7
|
+
Order,
|
|
8
|
+
ProductDetail,
|
|
9
|
+
ProductListResult,
|
|
10
|
+
ProductVariant,
|
|
11
|
+
ShippingPolicy,
|
|
12
|
+
StockCheckResult,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
export type ConfirmOrderPaymentInput = {
|
|
16
|
+
paymentId: string;
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the open checkout that placement promotes to a paid order. The
|
|
19
|
+
* Console resolves the checkout by `pgPaymentId` or by this `orderNumber`
|
|
20
|
+
* (→ `checkoutToken`); the synchronous return path always has it, the webhook
|
|
21
|
+
* backstop may not.
|
|
22
|
+
*/
|
|
23
|
+
orderNumber?: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
/** Provider-verified amount (server re-fetch), not a client-supplied value. */
|
|
26
|
+
amount: number;
|
|
27
|
+
providerEventId?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type PaymentStateInput = {
|
|
31
|
+
paymentId: string;
|
|
32
|
+
orderNumber?: string;
|
|
33
|
+
provider: string;
|
|
34
|
+
providerEventId?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type OrderLookupInput = {
|
|
38
|
+
paymentId: string;
|
|
39
|
+
provider: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The storefront commerce port. The cart is server-authoritative
|
|
44
|
+
* (`commerce.cart.*`): the browser never holds cart contents, only the
|
|
45
|
+
* unguessable `cartToken` in an HttpOnly cookie. Ordering goes through
|
|
46
|
+
* `orders.checkout({ cartId })`; the populated order is resolved by PG payment
|
|
47
|
+
* id (`getByPaymentId`) — there is no local order index.
|
|
48
|
+
*/
|
|
49
|
+
export type CommerceProvider = {
|
|
50
|
+
// Catalog
|
|
51
|
+
listProducts(input?: { limit?: number }): Promise<ProductListResult>;
|
|
52
|
+
getProductBySlug(slug: string): Promise<ProductDetail | null>;
|
|
53
|
+
getVariantsByIds(ids: string[]): Promise<ProductVariant[]>;
|
|
54
|
+
getShippingPolicy(): Promise<ShippingPolicy>;
|
|
55
|
+
checkStock(items: CartLine[]): Promise<StockCheckResult>;
|
|
56
|
+
|
|
57
|
+
// Server cart
|
|
58
|
+
createCart(): Promise<{ cartToken: string }>;
|
|
59
|
+
getCart(cartToken: string): Promise<CartView | null>;
|
|
60
|
+
addCartItem(input: { cartToken: string; item: CartItemRef }): Promise<CartView>;
|
|
61
|
+
updateCartItem(input: {
|
|
62
|
+
cartToken: string;
|
|
63
|
+
cartItemId: string;
|
|
64
|
+
quantity: number;
|
|
65
|
+
}): Promise<CartView>;
|
|
66
|
+
removeCartItem(input: {
|
|
67
|
+
cartToken: string;
|
|
68
|
+
cartItemId: string;
|
|
69
|
+
}): Promise<CartView>;
|
|
70
|
+
clearCart(input: { cartToken: string }): Promise<CartView>;
|
|
71
|
+
|
|
72
|
+
// Checkout + payment lifecycle
|
|
73
|
+
checkoutCart(input: CheckoutCartInput): Promise<CreatePendingOrderResult>;
|
|
74
|
+
getOrderByPaymentId(input: OrderLookupInput): Promise<Order | null>;
|
|
75
|
+
confirmOrderPayment(input: ConfirmOrderPaymentInput): Promise<Order>;
|
|
76
|
+
markPaymentFailed(input: PaymentStateInput): Promise<Order | null>;
|
|
77
|
+
cancelPendingOrder(input: PaymentStateInput): Promise<Order | null>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The cart read/write subset of {@link CommerceProvider}. A logged-in customer
|
|
82
|
+
* operates the cart through a **customer-JWT-scoped** client (the JWT binds the
|
|
83
|
+
* customer; a publishable key alone cannot read/write a customer-bound cart), so
|
|
84
|
+
* `server-cart.ts` resolves one of these per request. Narrowing to the cart ops
|
|
85
|
+
* keeps the customer client — which cannot perform secret-key-only checkout /
|
|
86
|
+
* payment / collection-write operations — from being handed those calls.
|
|
87
|
+
*/
|
|
88
|
+
export type CartProvider = Pick<
|
|
89
|
+
CommerceProvider,
|
|
90
|
+
| "createCart"
|
|
91
|
+
| "getCart"
|
|
92
|
+
| "addCartItem"
|
|
93
|
+
| "updateCartItem"
|
|
94
|
+
| "removeCartItem"
|
|
95
|
+
| "clearCart"
|
|
96
|
+
>;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CartLine, ProductVariant, StockCheckResult } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function checkVariantStock(input: {
|
|
4
|
+
lines: CartLine[];
|
|
5
|
+
variants: ProductVariant[];
|
|
6
|
+
}): StockCheckResult {
|
|
7
|
+
const variantById = new Map(input.variants.map((variant) => [variant.id, variant]));
|
|
8
|
+
|
|
9
|
+
const lines = input.lines.map((line) => {
|
|
10
|
+
const variant = variantById.get(line.variantId);
|
|
11
|
+
if (!variant) {
|
|
12
|
+
return {
|
|
13
|
+
...line,
|
|
14
|
+
availableQuantity: 0,
|
|
15
|
+
ok: false,
|
|
16
|
+
reason: "variant_not_found" as const,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const availableQuantity = variant.isUnlimited
|
|
21
|
+
? null
|
|
22
|
+
: Math.max(0, variant.stock - variant.reservedStock);
|
|
23
|
+
const ok = availableQuantity === null || availableQuantity >= line.quantity;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...line,
|
|
27
|
+
availableQuantity,
|
|
28
|
+
ok,
|
|
29
|
+
reason: ok ? undefined : ("insufficient_stock" as const),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
ok: lines.every((line) => line.ok),
|
|
35
|
+
lines,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export type CommerceImage = {
|
|
2
|
+
url: string
|
|
3
|
+
alt?: string
|
|
4
|
+
width?: number
|
|
5
|
+
height?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type Product = {
|
|
9
|
+
id: string
|
|
10
|
+
slug: string
|
|
11
|
+
title: string
|
|
12
|
+
description?: string
|
|
13
|
+
thumbnail?: CommerceImage | null
|
|
14
|
+
images: CommerceImage[]
|
|
15
|
+
status: 'draft' | 'published' | 'archived'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ProductDetail = {
|
|
19
|
+
product: Product
|
|
20
|
+
variants: ProductVariant[]
|
|
21
|
+
options: ProductOption[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ProductOption = {
|
|
25
|
+
id: string
|
|
26
|
+
slug: string
|
|
27
|
+
title: string
|
|
28
|
+
values: ProductOptionValue[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ProductOptionValue = {
|
|
32
|
+
id: string
|
|
33
|
+
slug: string
|
|
34
|
+
value: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type ProductVariantOptionValue = {
|
|
38
|
+
optionId: string
|
|
39
|
+
valueId: string
|
|
40
|
+
valueSlug: string
|
|
41
|
+
value: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ProductVariant = {
|
|
45
|
+
id: string
|
|
46
|
+
productId: string
|
|
47
|
+
title?: string
|
|
48
|
+
sku?: string
|
|
49
|
+
price: number
|
|
50
|
+
stock: number
|
|
51
|
+
reservedStock: number
|
|
52
|
+
isUnlimited: boolean
|
|
53
|
+
requiresShipping?: boolean | null
|
|
54
|
+
optionValues: ProductVariantOptionValue[]
|
|
55
|
+
images: CommerceImage[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type CartLine = {
|
|
59
|
+
variantId: string
|
|
60
|
+
quantity: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reference used to add a line to the server cart. The catalog identity
|
|
65
|
+
* (`product`/`variant`/`option`) is what `commerce.cart.addItem` expects — the
|
|
66
|
+
* cart, not the browser, owns price and stock.
|
|
67
|
+
*/
|
|
68
|
+
export type CartItemRef = {
|
|
69
|
+
productId: string
|
|
70
|
+
variantId: string
|
|
71
|
+
optionId?: string
|
|
72
|
+
quantity: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A single line of the server cart, enriched with catalog display fields so the
|
|
77
|
+
* storefront can render it without a second round-trip. `cartItemId` is the
|
|
78
|
+
* handle used by update/remove.
|
|
79
|
+
*/
|
|
80
|
+
export type CartItemView = {
|
|
81
|
+
cartItemId: string
|
|
82
|
+
productId: string
|
|
83
|
+
variantId: string
|
|
84
|
+
quantity: number
|
|
85
|
+
unitAmount: number
|
|
86
|
+
lineAmount: number
|
|
87
|
+
requiresShipping?: boolean | null
|
|
88
|
+
productTitle: string
|
|
89
|
+
variantTitle?: string
|
|
90
|
+
image?: CommerceImage | null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The server cart projected for the storefront. `id` is the cart's DB id used
|
|
95
|
+
* by `orders.checkout({ cartId })`; the unguessable `cartToken` capability is
|
|
96
|
+
* never part of this view — it lives only in the HttpOnly cookie.
|
|
97
|
+
* `totalAmount` is the single payable quote (subtotal − discount + shipping,
|
|
98
|
+
* computed and stored by the Console cart).
|
|
99
|
+
*/
|
|
100
|
+
export type CartView = {
|
|
101
|
+
id: string
|
|
102
|
+
currency: string
|
|
103
|
+
subtotalAmount: number
|
|
104
|
+
shippingAmount: number
|
|
105
|
+
discountAmount: number
|
|
106
|
+
totalAmount: number
|
|
107
|
+
discountCode?: string
|
|
108
|
+
items: CartItemView[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type ShippingPolicy = {
|
|
112
|
+
currency: string
|
|
113
|
+
baseAmount: number
|
|
114
|
+
freeAboveAmount?: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type PricedOrderLine = CartLine & {
|
|
118
|
+
unitAmount: number
|
|
119
|
+
lineAmount: number
|
|
120
|
+
variant: ProductVariant
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type PricedOrder = {
|
|
124
|
+
currency: string
|
|
125
|
+
lines: PricedOrderLine[]
|
|
126
|
+
subtotalAmount: number
|
|
127
|
+
shippingAmount: number
|
|
128
|
+
totalAmount: number
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type StockCheckLine = CartLine & {
|
|
132
|
+
availableQuantity: number | null
|
|
133
|
+
ok: boolean
|
|
134
|
+
reason?: 'variant_not_found' | 'insufficient_stock'
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type StockCheckResult = {
|
|
138
|
+
ok: boolean
|
|
139
|
+
lines: StockCheckLine[]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export type CustomerSnapshot = {
|
|
143
|
+
name: string
|
|
144
|
+
email: string
|
|
145
|
+
phone: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type ShippingAddress = {
|
|
149
|
+
recipientName: string
|
|
150
|
+
phone: string
|
|
151
|
+
postalCode: string
|
|
152
|
+
address: string
|
|
153
|
+
detailAddress: string
|
|
154
|
+
deliveryMessage?: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type OrderItem = {
|
|
158
|
+
variantId: string
|
|
159
|
+
productTitle: string
|
|
160
|
+
variantTitle?: string
|
|
161
|
+
quantity: number
|
|
162
|
+
unitAmount: number
|
|
163
|
+
lineAmount: number
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export type PaymentTransaction = {
|
|
167
|
+
paymentId: string
|
|
168
|
+
provider: string
|
|
169
|
+
status: 'pending' | 'paid' | 'failed' | 'canceled'
|
|
170
|
+
amount: number
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type Order = {
|
|
174
|
+
id: string
|
|
175
|
+
orderNumber: string
|
|
176
|
+
displayStatus: 'pending' | 'paid' | 'canceled'
|
|
177
|
+
items: OrderItem[]
|
|
178
|
+
customerSnapshot: CustomerSnapshot
|
|
179
|
+
shippingAddress?: ShippingAddress
|
|
180
|
+
subtotalAmount: number
|
|
181
|
+
shippingAmount: number
|
|
182
|
+
totalAmount: number
|
|
183
|
+
transactions: PaymentTransaction[]
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type ProductListResult = {
|
|
187
|
+
products: ProductDetail[]
|
|
188
|
+
total: number
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Checkout input. The cart contents live server-side (addressed by `cartToken`
|
|
193
|
+
* from the HttpOnly cookie); the storefront supplies only the buyer + shipping
|
|
194
|
+
* snapshot. No client-supplied lines or totals — the cart is the quote.
|
|
195
|
+
*/
|
|
196
|
+
export type CheckoutCartInput = {
|
|
197
|
+
cartToken: string
|
|
198
|
+
customerSnapshot: CustomerSnapshot
|
|
199
|
+
shippingAddress?: ShippingAddress
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export type CreatePendingOrderResult = {
|
|
203
|
+
order: Order
|
|
204
|
+
paymentId: string
|
|
205
|
+
paymentName: string
|
|
206
|
+
amount: number
|
|
207
|
+
currency: string
|
|
208
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ProductDetail, ProductVariant } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function resolveVariantFromSelection(
|
|
4
|
+
detail: ProductDetail,
|
|
5
|
+
selectedValueIdsByOptionId: Record<string, string | undefined>,
|
|
6
|
+
): ProductVariant | null {
|
|
7
|
+
const requiredOptionIds = detail.options.map((option) => option.id);
|
|
8
|
+
if (requiredOptionIds.some((optionId) => !selectedValueIdsByOptionId[optionId])) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
detail.variants.find((variant) =>
|
|
14
|
+
requiredOptionIds.every((optionId) =>
|
|
15
|
+
variant.optionValues.some(
|
|
16
|
+
(value) =>
|
|
17
|
+
value.optionId === optionId &&
|
|
18
|
+
value.valueId === selectedValueIdsByOptionId[optionId],
|
|
19
|
+
),
|
|
20
|
+
),
|
|
21
|
+
) ?? null
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import "../server-only-guard.ts";
|
|
2
|
+
import type { CustomerAuthClient } from "./client.server.ts";
|
|
3
|
+
import { createCustomerClient } from "./client.server.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Customer auth core logic, isolated from `next/headers` and the SDK transport so
|
|
7
|
+
* it unit-tests with a fake client. Route handlers call these and then apply the
|
|
8
|
+
* returned cookie effect; render paths use {@link loadCustomer} read-only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface CustomerSummary {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
email?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoginInput {
|
|
18
|
+
email: string;
|
|
19
|
+
password: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RegisterInput {
|
|
23
|
+
name: string;
|
|
24
|
+
email: string;
|
|
25
|
+
password: string;
|
|
26
|
+
phone?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Result of a login/register attempt the route handler turns into a response. */
|
|
30
|
+
export type AuthResult =
|
|
31
|
+
| { ok: true; token: string | null; customer: CustomerSummary | null }
|
|
32
|
+
| { ok: false; status: number; message: string };
|
|
33
|
+
|
|
34
|
+
/** A client factory so tests can inject a fake SDK client. */
|
|
35
|
+
export type CustomerClientFactory = (
|
|
36
|
+
token: string | null,
|
|
37
|
+
) => Promise<CustomerAuthClient | null>;
|
|
38
|
+
|
|
39
|
+
const UNCONFIGURED: AuthResult = {
|
|
40
|
+
ok: false,
|
|
41
|
+
status: 503,
|
|
42
|
+
message: "Customer accounts are not configured for this storefront.",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function toSummary(customer: unknown): CustomerSummary | null {
|
|
46
|
+
if (!customer || typeof customer !== "object") return null;
|
|
47
|
+
const c = customer as Record<string, unknown>;
|
|
48
|
+
if (typeof c.id !== "string" || typeof c.name !== "string") return null;
|
|
49
|
+
return {
|
|
50
|
+
id: c.id,
|
|
51
|
+
name: c.name,
|
|
52
|
+
email: typeof c.email === "string" ? c.email : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function statusOf(error: unknown): number {
|
|
57
|
+
const status = (error as { status?: unknown })?.status;
|
|
58
|
+
return typeof status === "number" ? status : 502;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function messageOf(error: unknown, fallback: string): string {
|
|
62
|
+
if (error instanceof Error && error.message) return error.message;
|
|
63
|
+
return fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Log in and return the JWT for the route to persist in the session cookie. */
|
|
67
|
+
export async function login(
|
|
68
|
+
input: LoginInput,
|
|
69
|
+
factory: CustomerClientFactory = (token) => createCustomerClient(token),
|
|
70
|
+
): Promise<AuthResult> {
|
|
71
|
+
const client = await factory(null);
|
|
72
|
+
if (!client) return UNCONFIGURED;
|
|
73
|
+
try {
|
|
74
|
+
const { token, customer } = await client.customer.auth.login(input);
|
|
75
|
+
return { ok: true, token, customer: toSummary(customer) };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
status: statusOf(error),
|
|
80
|
+
message: messageOf(error, "Login failed"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register an account. The register endpoint returns the customer (no token), so
|
|
87
|
+
* the caller logs in afterward to mint the session; we surface only the customer
|
|
88
|
+
* here and let the route chain the login.
|
|
89
|
+
*/
|
|
90
|
+
export async function register(
|
|
91
|
+
input: RegisterInput,
|
|
92
|
+
factory: CustomerClientFactory = (token) => createCustomerClient(token),
|
|
93
|
+
): Promise<AuthResult> {
|
|
94
|
+
const client = await factory(null);
|
|
95
|
+
if (!client) return UNCONFIGURED;
|
|
96
|
+
try {
|
|
97
|
+
const { customer } = await client.customer.auth.register(input);
|
|
98
|
+
return { ok: true, token: null, customer: toSummary(customer) };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
status: statusOf(error),
|
|
103
|
+
message: messageOf(error, "Registration failed"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the current customer from a session token, for render paths. Returns
|
|
110
|
+
* `null` (guest) when there is no token, auth is unconfigured, the token is
|
|
111
|
+
* expired/invalid (the SDK's `me()` returns `null` on a 401), **or** the backend
|
|
112
|
+
* call fails — a render path must degrade to guest, never throw. We never
|
|
113
|
+
* refresh here: there is no infinite loop against a cookie a render path cannot
|
|
114
|
+
* rewrite. The stale (HttpOnly, harmless) cookie is overwritten on the next
|
|
115
|
+
* login or dropped on logout.
|
|
116
|
+
*/
|
|
117
|
+
export async function loadCustomer(
|
|
118
|
+
token: string | null,
|
|
119
|
+
factory: CustomerClientFactory = (t) => createCustomerClient(t),
|
|
120
|
+
): Promise<CustomerSummary | null> {
|
|
121
|
+
if (!token) return null;
|
|
122
|
+
try {
|
|
123
|
+
const client = await factory(token);
|
|
124
|
+
if (!client) return null;
|
|
125
|
+
return toSummary(await client.customer.auth.me());
|
|
126
|
+
} catch {
|
|
127
|
+
// 5xx / timeout / network error — fall back to guest rather than crash the
|
|
128
|
+
// page. `me()` already maps 401 to `null` without throwing.
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login cart-sync core, isolated from `next/headers` and the SDK transport so it
|
|
3
|
+
* unit-tests with a fake commerce client. The route wrapper
|
|
4
|
+
* (`lib/cart/sync-on-login.server.ts`) reads the guest cart cookie, builds a
|
|
5
|
+
* customer-JWT commerce client, calls this, and applies the cookie effect.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The merge/mine surface the login cart-sync consumes (customer-JWT scoped). */
|
|
9
|
+
export interface CartSyncCommerce {
|
|
10
|
+
cart: {
|
|
11
|
+
merge(params: {
|
|
12
|
+
guestCartToken: string;
|
|
13
|
+
}): Promise<{ cartToken: string | null }>;
|
|
14
|
+
mine(): Promise<{ cartToken: string | null }>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CartSyncResult {
|
|
19
|
+
/** The resolved customer cart token to persist, or `null` to clear the cookie. */
|
|
20
|
+
cartToken: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* After login, resolve the customer's active-cart token. If a pre-login guest
|
|
25
|
+
* cart exists, union/claim it into the customer cart (`merge`); otherwise load
|
|
26
|
+
* the existing customer cart (`mine`). The returned token is what the caller
|
|
27
|
+
* persists in the cart cookie (or clears the cookie when `null`).
|
|
28
|
+
*
|
|
29
|
+
* `merge` returns the resolved customer cart's token on **both** the `claimed`
|
|
30
|
+
* (guest cart taken over) and `merged` (lines unioned into an existing customer
|
|
31
|
+
* cart) branches, so the guest cookie always ends up pointing at the customer
|
|
32
|
+
* cart — never the abandoned guest cart.
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveCustomerCartToken(
|
|
35
|
+
commerce: CartSyncCommerce,
|
|
36
|
+
guestCartToken: string | null,
|
|
37
|
+
): Promise<CartSyncResult> {
|
|
38
|
+
if (guestCartToken) {
|
|
39
|
+
const { cartToken } = await commerce.cart.merge({ guestCartToken });
|
|
40
|
+
return { cartToken };
|
|
41
|
+
}
|
|
42
|
+
const { cartToken } = await commerce.cart.mine();
|
|
43
|
+
return { cartToken };
|
|
44
|
+
}
|