@vitrine-kit/vitrine 0.3.1 → 0.4.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/index.js +102 -83
- package/kit/registry/README.md +9 -0
- package/kit/registry/_index.json +74 -0
- package/kit/registry/cart/docs/cart.md +13 -0
- package/kit/registry/cart/feature.json +51 -0
- package/kit/registry/cart/files/app/(frontend)/cart/page.tsx +21 -0
- package/kit/registry/cart/files/app/api/cart/route.ts +41 -0
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +40 -0
- package/kit/registry/cart/files/components/cart/CartIndicator.tsx +11 -0
- package/kit/registry/cart/files/components/cart/CartLineItem.tsx +27 -0
- package/kit/registry/cart/files/components/cart/CartSummary.tsx +31 -0
- package/kit/registry/cart/files/components/cart/CartView.tsx +31 -0
- package/kit/registry/cart/files/lib/cart/data.ts +12 -0
- package/kit/registry/cart/files/lib/cart/register.ts +10 -0
- package/kit/registry/catalog/docs/catalog.md +18 -0
- package/kit/registry/catalog/feature.json +43 -0
- package/kit/registry/catalog/files/components/catalog/CategoryNav.tsx +26 -0
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +42 -0
- package/kit/registry/catalog/files/components/catalog/ProductGrid.tsx +25 -0
- package/kit/registry/catalog/files/lib/catalog/data.ts +24 -0
- package/kit/registry/catalog/files/lib/catalog/register.ts +8 -0
- package/kit/registry/checkout/docs/checkout.md +17 -0
- package/kit/registry/checkout/feature.json +47 -0
- package/kit/registry/checkout/files/app/api/checkout/route.ts +14 -0
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +31 -0
- package/kit/registry/checkout/files/lib/checkout/fulfill.ts +72 -0
- package/kit/registry/checkout/files/lib/checkout/register.ts +8 -0
- package/kit/registry/checkout-paddle/docs/checkout-paddle.md +22 -0
- package/kit/registry/checkout-paddle/feature.json +62 -0
- package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +22 -0
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/provider.ts +59 -0
- package/kit/registry/checkout-paddle/files/lib/checkout-paddle/register.ts +7 -0
- package/kit/registry/checkout-stripe/docs/checkout-stripe.md +17 -0
- package/kit/registry/checkout-stripe/feature.json +54 -0
- package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +23 -0
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/provider.ts +73 -0
- package/kit/registry/checkout-stripe/files/lib/checkout-stripe/register.ts +7 -0
- package/kit/registry/checkout-yookassa/docs/checkout-yookassa.md +20 -0
- package/kit/registry/checkout-yookassa/feature.json +52 -0
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +22 -0
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/provider.ts +82 -0
- package/kit/registry/checkout-yookassa/files/lib/checkout-yookassa/register.ts +7 -0
- package/kit/registry/product-page/docs/product-page.md +16 -0
- package/kit/registry/product-page/feature.json +39 -0
- package/kit/registry/product-page/files/components/product/ProductGallery.tsx +37 -0
- package/kit/registry/product-page/files/components/product/ProductView.tsx +42 -0
- package/kit/registry/product-page/files/lib/product/data.ts +10 -0
- package/kit/registry/seo/docs/seo.md +14 -0
- package/kit/registry/seo/feature.json +35 -0
- package/kit/registry/seo/files/components/seo/JsonLd.tsx +15 -0
- package/kit/registry/seo/files/lib/seo/jsonld.ts +29 -0
- package/kit/registry/seo/files/lib/seo/metadata.ts +35 -0
- package/kit/registry/stubs/route-glue.d.ts +45 -0
- package/kit/registry/tsconfig.json +21 -0
- package/kit/registry/tsconfig.routes.json +9 -0
- package/kit/templates/README.md +27 -0
- package/kit/templates/backend-payload/files/.dockerignore +8 -0
- package/kit/templates/backend-payload/files/Dockerfile +29 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/not-found.tsx +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/[[...segments]]/page.tsx +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/admin/importMap.js +3 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/[...slug]/route.ts +18 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/graphql/route.ts +6 -0
- package/kit/templates/backend-payload/files/app/(payload)/api/graphql-playground/route.ts +5 -0
- package/kit/templates/backend-payload/files/app/(payload)/custom.scss +1 -0
- package/kit/templates/backend-payload/files/app/(payload)/layout.tsx +23 -0
- package/kit/templates/backend-payload/files/docker-compose.yml +35 -0
- package/kit/templates/backend-payload/files/lib/adapter/db-decision.ts +43 -0
- package/kit/templates/backend-payload/files/lib/adapter/db-ping.ts +29 -0
- package/kit/templates/backend-payload/files/lib/adapter/db.ts +27 -0
- package/kit/templates/backend-payload/files/lib/adapter/index.ts +31 -0
- package/kit/templates/backend-payload/files/lib/adapter/map.ts +112 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +79 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +154 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-types.ts +50 -0
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +23 -0
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +79 -0
- package/kit/templates/backend-payload/files/lib/seed/guards.ts +5 -0
- package/kit/templates/backend-payload/files/lib/seed/richtext.ts +26 -0
- package/kit/templates/backend-payload/files/lib/seed/run.ts +51 -0
- package/kit/templates/backend-payload/files/next.config.mjs +10 -0
- package/kit/templates/backend-payload/files/payload.config.ts +32 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-1.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-2.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-3.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-4.svg +5 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-5.svg +5 -0
- package/kit/templates/backend-vendure/files/.dockerignore +8 -0
- package/kit/templates/backend-vendure/files/Dockerfile +24 -0
- package/kit/templates/backend-vendure/files/docker-compose.yml +50 -0
- package/kit/templates/backend-vendure/files/lib/adapter/graphql.ts +27 -0
- package/kit/templates/backend-vendure/files/lib/adapter/index.ts +20 -0
- package/kit/templates/backend-vendure/files/lib/adapter/map.ts +107 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +62 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-commerce.ts +76 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-types.ts +61 -0
- package/kit/templates/backend-vendure/files/lib/seed/populate.ts +22 -0
- package/kit/templates/backend-vendure/files/src/index.ts +15 -0
- package/kit/templates/backend-vendure/files/vendure-config.ts +56 -0
- package/kit/templates/base/files/.claude/commands/add-feature.md +18 -0
- package/kit/templates/base/files/.claude/commands/design.md +14 -0
- package/kit/templates/base/files/.claude/commands/doctor.md +15 -0
- package/kit/templates/base/files/.claude/commands/setup.md +15 -0
- package/kit/templates/base/files/.claude/commands/update.md +16 -0
- package/kit/templates/base/files/AGENTS.md +20 -0
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +28 -0
- package/kit/templates/base/files/app/(frontend)/layout.tsx +32 -0
- package/kit/templates/base/files/app/(frontend)/page.tsx +27 -0
- package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +43 -0
- package/kit/templates/base/files/app/globals.css +6 -0
- package/kit/templates/base/files/components/layout/SiteFooter.tsx +15 -0
- package/kit/templates/base/files/components/layout/SiteHeader.tsx +23 -0
- package/kit/templates/base/files/design/README.md +9 -0
- package/kit/templates/base/files/lib/site.ts +10 -0
- package/kit/templates/base/files/next.config.mjs +7 -0
- package/kit/templates/base/files/postcss.config.mjs +7 -0
- package/kit/templates/base/files/public/.gitkeep +0 -0
- package/kit/templates/base/files/tailwind.config.ts +13 -0
- package/kit/templates/base/files/tsconfig.json +25 -0
- package/kit/templates/tsconfig.json +18 -0
- package/package.json +5 -4
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "checkout-paddle",
|
|
4
|
+
"title": "Payment — Paddle",
|
|
5
|
+
"kitVersion": "0.4.0",
|
|
6
|
+
"requiresContracts": ">=1.0.0 <2.0.0",
|
|
7
|
+
"tier": [
|
|
8
|
+
"simple-store",
|
|
9
|
+
"full-store"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"checkout"
|
|
13
|
+
],
|
|
14
|
+
"corePackages": {
|
|
15
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"npm": [
|
|
18
|
+
"@paddle/paddle-node-sdk@^2.0.0"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"from": "files/lib/checkout-paddle/",
|
|
23
|
+
"to": "lib/checkout-paddle/"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"from": "files/app/",
|
|
27
|
+
"to": "app/"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"config": {
|
|
31
|
+
"set": {
|
|
32
|
+
"features.checkout-paddle": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"payment": {
|
|
36
|
+
"provider": "paddle"
|
|
37
|
+
},
|
|
38
|
+
"env": [
|
|
39
|
+
{
|
|
40
|
+
"key": "PADDLE_API_KEY",
|
|
41
|
+
"required": true
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"key": "PADDLE_WEBHOOK_SECRET",
|
|
45
|
+
"required": true
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"key": "PADDLE_ENVIRONMENT",
|
|
49
|
+
"required": false
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"key": "PADDLE_CHECKOUT_URL",
|
|
53
|
+
"required": false
|
|
54
|
+
}
|
|
55
|
+
],
|
|
56
|
+
"claudeDoc": "docs/checkout-paddle.md",
|
|
57
|
+
"conflicts": [
|
|
58
|
+
"checkout-stripe",
|
|
59
|
+
"checkout-yookassa"
|
|
60
|
+
],
|
|
61
|
+
"removable": true
|
|
62
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Paddle webhook: paddleProvider.verifyWebhook (Paddle-Signature) →
|
|
2
|
+
// normalized event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
|
|
3
|
+
import { NextResponse } from 'next/server';
|
|
4
|
+
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
5
|
+
import { paddleProvider } from '@/lib/checkout-paddle/provider';
|
|
6
|
+
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
7
|
+
|
|
8
|
+
export async function POST(req: Request) {
|
|
9
|
+
const rawBody = await req.text();
|
|
10
|
+
const headers = { 'paddle-signature': req.headers.get('paddle-signature') };
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const result = await handlePaymentWebhook({
|
|
14
|
+
provider: paddleProvider,
|
|
15
|
+
req: { rawBody, headers },
|
|
16
|
+
handlers: { onCheckoutCompleted: (event) => fulfillOrderFromEvent(event, 'paddle') },
|
|
17
|
+
});
|
|
18
|
+
return NextResponse.json(result);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// PaymentProvider over Paddle Billing. The SDK lives here (in the feature), not in the core.
|
|
2
|
+
// createCheckout creates a transaction with non-catalog line items (prices set on the fly
|
|
3
|
+
// from the cart) and custom_data.cartId → returns the hosted checkout URL. verifyWebhook
|
|
4
|
+
// verifies the Paddle-Signature (HMAC) and normalizes the event.
|
|
5
|
+
//
|
|
6
|
+
// Paddle expects amounts as a string in the currency's minor units (like our Money), so
|
|
7
|
+
// we pass String(unitPrice) as-is. For hosted checkout, the Paddle dashboard needs a
|
|
8
|
+
// default payment link OR set PADDLE_CHECKOUT_URL (override).
|
|
9
|
+
import { Environment, Paddle, type EventEntity } from '@paddle/paddle-node-sdk';
|
|
10
|
+
import type { CreateCheckoutArgs, NormalizedPaymentEvent, PaymentProvider, PaymentWebhookRequest } from '@vitrine-kit/core';
|
|
11
|
+
|
|
12
|
+
function client(): Paddle {
|
|
13
|
+
return new Paddle(process.env.PADDLE_API_KEY ?? '', {
|
|
14
|
+
environment:
|
|
15
|
+
process.env.PADDLE_ENVIRONMENT === 'production' ? Environment.production : Environment.sandbox,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const paddleProvider: PaymentProvider = {
|
|
20
|
+
name: 'paddle',
|
|
21
|
+
|
|
22
|
+
async createCheckout(args: CreateCheckoutArgs): Promise<{ redirectUrl: string }> {
|
|
23
|
+
const { cart, baseUrl, cancelPath = '/cart' } = args;
|
|
24
|
+
const paddle = client();
|
|
25
|
+
const txn = await paddle.transactions.create({
|
|
26
|
+
items: cart.lines.map((l) => ({
|
|
27
|
+
quantity: l.quantity,
|
|
28
|
+
price: {
|
|
29
|
+
description: l.title,
|
|
30
|
+
name: l.title,
|
|
31
|
+
unitPrice: { amount: String(l.unitPrice), currencyCode: cart.currency },
|
|
32
|
+
product: { name: l.title, taxCategory: 'standard' },
|
|
33
|
+
},
|
|
34
|
+
})),
|
|
35
|
+
customData: { cartId: cart.id },
|
|
36
|
+
...(process.env.PADDLE_CHECKOUT_URL ? { checkout: { url: process.env.PADDLE_CHECKOUT_URL } } : {}),
|
|
37
|
+
});
|
|
38
|
+
return { redirectUrl: txn.checkout?.url ?? `${baseUrl}${cancelPath}` };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
|
|
42
|
+
const paddle = client();
|
|
43
|
+
const secret = process.env.PADDLE_WEBHOOK_SECRET ?? '';
|
|
44
|
+
const signature = req.headers['paddle-signature'] ?? '';
|
|
45
|
+
// Throws on an invalid signature — handlePaymentWebhook returns a 400.
|
|
46
|
+
const event = (await paddle.webhooks.unmarshal(req.rawBody, secret, signature)) as EventEntity | null;
|
|
47
|
+
if (!event) return { kind: 'unknown', raw: req.rawBody };
|
|
48
|
+
|
|
49
|
+
if (event.eventType === 'transaction.completed' || event.eventType === 'transaction.paid') {
|
|
50
|
+
const data = event.data as { id?: string; customData?: { cartId?: string } | null };
|
|
51
|
+
const cartId = (data.customData ?? undefined)?.cartId;
|
|
52
|
+
return { kind: 'checkout_completed', cartId, providerRef: data.id, raw: event };
|
|
53
|
+
}
|
|
54
|
+
if (event.eventType === 'transaction.payment_failed') {
|
|
55
|
+
return { kind: 'payment_failed', raw: event };
|
|
56
|
+
}
|
|
57
|
+
return { kind: 'unknown', raw: event };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Registers the Paddle payment provider. The CLI calls this from lib/payments.ts.
|
|
2
|
+
import { payments } from '@vitrine-kit/core';
|
|
3
|
+
import { paddleProvider } from './provider.js';
|
|
4
|
+
|
|
5
|
+
export function registerCheckoutPaddleProvider(): void {
|
|
6
|
+
payments.register(paddleProvider);
|
|
7
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Feature: checkout-stripe (Stripe payment provider)
|
|
2
|
+
|
|
3
|
+
A Stripe Hosted Checkout provider for the `checkout` scaffold (which it depends on). Installed
|
|
4
|
+
instead of `checkout-paddle` / `checkout-yookassa` (mutually exclusive). The critical
|
|
5
|
+
logic (webhook dispatcher, order from cart) lives in `@vitrine-kit/core`.
|
|
6
|
+
|
|
7
|
+
- **Provider:** `lib/checkout-stripe/provider.ts` → `stripeProvider`
|
|
8
|
+
(`PaymentProvider`): `createCheckout` creates a Stripe session; `verifyWebhook`
|
|
9
|
+
verifies the signature via the Stripe SDK and normalizes the event.
|
|
10
|
+
- **Registration:** `registerCheckoutStripeProvider()` (called from `lib/payments.ts`),
|
|
11
|
+
sets `integrations.payments: "stripe"` in `site.config`.
|
|
12
|
+
- **API (Next glue):** `POST /api/webhooks/stripe` → `handlePaymentWebhook` →
|
|
13
|
+
`fulfillOrderFromEvent` (the `checkout` feature's shared code).
|
|
14
|
+
- **env:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` (required).
|
|
15
|
+
|
|
16
|
+
Flow: cart → `Checkout` → redirect to Stripe → webhook
|
|
17
|
+
`checkout.session.completed` → order in the admin, cart `converted`.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "checkout-stripe",
|
|
4
|
+
"title": "Payment — Stripe",
|
|
5
|
+
"kitVersion": "0.4.0",
|
|
6
|
+
"requiresContracts": ">=1.0.0 <2.0.0",
|
|
7
|
+
"tier": [
|
|
8
|
+
"simple-store",
|
|
9
|
+
"full-store"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"checkout"
|
|
13
|
+
],
|
|
14
|
+
"corePackages": {
|
|
15
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"npm": [
|
|
18
|
+
"stripe@^17.0.0"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
{
|
|
22
|
+
"from": "files/lib/checkout-stripe/",
|
|
23
|
+
"to": "lib/checkout-stripe/"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"from": "files/app/",
|
|
27
|
+
"to": "app/"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"config": {
|
|
31
|
+
"set": {
|
|
32
|
+
"features.checkout-stripe": true
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"payment": {
|
|
36
|
+
"provider": "stripe"
|
|
37
|
+
},
|
|
38
|
+
"env": [
|
|
39
|
+
{
|
|
40
|
+
"key": "STRIPE_SECRET_KEY",
|
|
41
|
+
"required": true
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"key": "STRIPE_WEBHOOK_SECRET",
|
|
45
|
+
"required": true
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"claudeDoc": "docs/checkout-stripe.md",
|
|
49
|
+
"conflicts": [
|
|
50
|
+
"checkout-paddle",
|
|
51
|
+
"checkout-yookassa"
|
|
52
|
+
],
|
|
53
|
+
"removable": true
|
|
54
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Stripe webhook: stripeProvider.verifyWebhook (Stripe SDK signature) → normalized
|
|
2
|
+
// event → handlePaymentWebhook → the shared fulfillOrderFromEvent creates an order from
|
|
3
|
+
// the cart and marks it converted. No provider-specific code here — just glue.
|
|
4
|
+
import { NextResponse } from 'next/server';
|
|
5
|
+
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
6
|
+
import { stripeProvider } from '@/lib/checkout-stripe/provider';
|
|
7
|
+
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
8
|
+
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
const rawBody = await req.text();
|
|
11
|
+
const headers = { 'stripe-signature': req.headers.get('stripe-signature') };
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const result = await handlePaymentWebhook({
|
|
15
|
+
provider: stripeProvider,
|
|
16
|
+
req: { rawBody, headers },
|
|
17
|
+
handlers: { onCheckoutCompleted: (event) => fulfillOrderFromEvent(event, 'stripe') },
|
|
18
|
+
});
|
|
19
|
+
return NextResponse.json(result);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 400 });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// PaymentProvider over Stripe Hosted Checkout. The Stripe SDK lives here (in the feature),
|
|
2
|
+
// not in the core: createCheckout creates a session, verifyWebhook verifies the signature and
|
|
3
|
+
// normalizes the event into a NormalizedPaymentEvent. Mapping the cart into Stripe line items
|
|
4
|
+
// (price_data — dynamic prices, unit_amount in minor units) also lives here.
|
|
5
|
+
import type { Cart } from '@vitrine-kit/contracts';
|
|
6
|
+
import type {
|
|
7
|
+
CreateCheckoutArgs,
|
|
8
|
+
NormalizedPaymentEvent,
|
|
9
|
+
PaymentProvider,
|
|
10
|
+
PaymentWebhookRequest,
|
|
11
|
+
} from '@vitrine-kit/core';
|
|
12
|
+
|
|
13
|
+
interface StripeLineItem {
|
|
14
|
+
quantity: number;
|
|
15
|
+
price_data: { currency: string; unit_amount: number; product_data: { name: string } };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cartToStripeLineItems(cart: Cart): StripeLineItem[] {
|
|
19
|
+
return cart.lines.map((l) => ({
|
|
20
|
+
quantity: l.quantity,
|
|
21
|
+
price_data: {
|
|
22
|
+
currency: cart.currency.toLowerCase(),
|
|
23
|
+
unit_amount: l.unitPrice,
|
|
24
|
+
product_data: { name: l.title },
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const stripeProvider: PaymentProvider = {
|
|
30
|
+
name: 'stripe',
|
|
31
|
+
|
|
32
|
+
async createCheckout(args: CreateCheckoutArgs): Promise<{ redirectUrl: string }> {
|
|
33
|
+
const { cart, baseUrl, successPath = '/order/success', cancelPath = '/cart' } = args;
|
|
34
|
+
const { default: Stripe } = await import('stripe');
|
|
35
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');
|
|
36
|
+
const session = await stripe.checkout.sessions.create({
|
|
37
|
+
mode: 'payment',
|
|
38
|
+
line_items: cartToStripeLineItems(cart),
|
|
39
|
+
success_url: `${baseUrl}${successPath}?session_id={CHECKOUT_SESSION_ID}`,
|
|
40
|
+
cancel_url: `${baseUrl}${cancelPath}`,
|
|
41
|
+
metadata: { cartId: cart.id },
|
|
42
|
+
});
|
|
43
|
+
return { redirectUrl: session.url ?? `${baseUrl}${cancelPath}` };
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
|
|
47
|
+
const { default: Stripe } = await import('stripe');
|
|
48
|
+
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '');
|
|
49
|
+
const secret = process.env.STRIPE_WEBHOOK_SECRET ?? '';
|
|
50
|
+
const signature = req.headers['stripe-signature'] ?? '';
|
|
51
|
+
// Throws on an invalid signature — handlePaymentWebhook surfaces it as a 400.
|
|
52
|
+
const event = stripe.webhooks.constructEvent(req.rawBody, signature, secret);
|
|
53
|
+
|
|
54
|
+
if (event.type === 'checkout.session.completed') {
|
|
55
|
+
const session = event.data.object as {
|
|
56
|
+
id?: string;
|
|
57
|
+
metadata?: { cartId?: string };
|
|
58
|
+
customer_details?: { email?: string };
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
kind: 'checkout_completed',
|
|
62
|
+
cartId: session.metadata?.cartId,
|
|
63
|
+
providerRef: session.id,
|
|
64
|
+
email: session.customer_details?.email,
|
|
65
|
+
raw: event,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (event.type === 'payment_intent.payment_failed') {
|
|
69
|
+
return { kind: 'payment_failed', raw: event };
|
|
70
|
+
}
|
|
71
|
+
return { kind: 'unknown', raw: event };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Registers the Stripe payment provider. The CLI calls this from lib/payments.ts.
|
|
2
|
+
import { payments } from '@vitrine-kit/core';
|
|
3
|
+
import { stripeProvider } from './provider.js';
|
|
4
|
+
|
|
5
|
+
export function registerCheckoutStripeProvider(): void {
|
|
6
|
+
payments.register(stripeProvider);
|
|
7
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Feature: checkout-yookassa (YooKassa payment provider)
|
|
2
|
+
|
|
3
|
+
A YooKassa (yookassa.ru) provider — Russian acquiring: cards, SBP, wallets. For the
|
|
4
|
+
`checkout` scaffold (which it depends on). Installed instead of `checkout-stripe` / `checkout-paddle`
|
|
5
|
+
(mutually exclusive). No SDK needed — REST `/v3/payments` + Basic auth.
|
|
6
|
+
|
|
7
|
+
- **Provider:** `lib/checkout-yookassa/provider.ts` → `yookassaProvider`
|
|
8
|
+
(`PaymentProvider`): `createCheckout` creates a payment (`confirmation.redirect`) →
|
|
9
|
+
`confirmation_url`; `verifyWebhook` confirms the payment with a follow-up request to the API.
|
|
10
|
+
- **Registration:** `registerCheckoutYookassaProvider()` (from `lib/payments.ts`),
|
|
11
|
+
sets `integrations.payments: "yookassa"`.
|
|
12
|
+
- **API (Next glue):** `POST /api/webhooks/yookassa` → `handlePaymentWebhook` →
|
|
13
|
+
`fulfillOrderFromEvent`.
|
|
14
|
+
- **env:** `YOOKASSA_SHOP_ID`, `YOOKASSA_SECRET_KEY` (required).
|
|
15
|
+
|
|
16
|
+
**Security:** YooKassa notifications are **not signed** — the provider re-verifies the
|
|
17
|
+
payment via `GET /v3/payments/{id}` and trusts only `status: "succeeded"`.
|
|
18
|
+
|
|
19
|
+
**Money:** YooKassa expects a decimal string (`"1990.00"`); `Money` is in minor
|
|
20
|
+
units, so we divide by 100 (RUB and most currencies have 2 decimal places).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "checkout-yookassa",
|
|
4
|
+
"title": "Payment — YooKassa",
|
|
5
|
+
"kitVersion": "0.4.0",
|
|
6
|
+
"requiresContracts": ">=1.0.0 <2.0.0",
|
|
7
|
+
"tier": [
|
|
8
|
+
"simple-store",
|
|
9
|
+
"full-store"
|
|
10
|
+
],
|
|
11
|
+
"registryDependencies": [
|
|
12
|
+
"checkout"
|
|
13
|
+
],
|
|
14
|
+
"corePackages": {
|
|
15
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"npm": [],
|
|
18
|
+
"files": [
|
|
19
|
+
{
|
|
20
|
+
"from": "files/lib/checkout-yookassa/",
|
|
21
|
+
"to": "lib/checkout-yookassa/"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"from": "files/app/",
|
|
25
|
+
"to": "app/"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"config": {
|
|
29
|
+
"set": {
|
|
30
|
+
"features.checkout-yookassa": true
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"payment": {
|
|
34
|
+
"provider": "yookassa"
|
|
35
|
+
},
|
|
36
|
+
"env": [
|
|
37
|
+
{
|
|
38
|
+
"key": "YOOKASSA_SHOP_ID",
|
|
39
|
+
"required": true
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"key": "YOOKASSA_SECRET_KEY",
|
|
43
|
+
"required": true
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"claudeDoc": "docs/checkout-yookassa.md",
|
|
47
|
+
"conflicts": [
|
|
48
|
+
"checkout-stripe",
|
|
49
|
+
"checkout-paddle"
|
|
50
|
+
],
|
|
51
|
+
"removable": true
|
|
52
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// YooKassa webhook: the notification is unsigned — yookassaProvider.verifyWebhook
|
|
2
|
+
// re-checks the payment via the API (we trust only succeeded) → normalized
|
|
3
|
+
// event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
|
|
4
|
+
import { NextResponse } from 'next/server';
|
|
5
|
+
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
6
|
+
import { yookassaProvider } from '@/lib/checkout-yookassa/provider';
|
|
7
|
+
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
8
|
+
|
|
9
|
+
export async function POST(req: Request) {
|
|
10
|
+
const rawBody = await req.text();
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const result = await handlePaymentWebhook({
|
|
14
|
+
provider: yookassaProvider,
|
|
15
|
+
req: { rawBody, headers: {} },
|
|
16
|
+
handlers: { onCheckoutCompleted: (event) => fulfillOrderFromEvent(event, 'yookassa') },
|
|
17
|
+
});
|
|
18
|
+
return NextResponse.json(result);
|
|
19
|
+
} catch (err) {
|
|
20
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// PaymentProvider over YooKassa (yookassa.ru) — Russian acquiring: cards, SBP, wallets.
|
|
2
|
+
// No SDK needed: REST /v3/payments + Basic auth (shopId:secretKey). The core doesn't pull in
|
|
3
|
+
// payment SDKs. createCheckout creates a payment with confirmation.redirect → confirmation_url.
|
|
4
|
+
//
|
|
5
|
+
// IMPORTANT: YooKassa notifications are NOT signed. verifyWebhook confirms authenticity
|
|
6
|
+
// with a follow-up API request (GET /v3/payments/{id}) and trusts only status=succeeded.
|
|
7
|
+
//
|
|
8
|
+
// YooKassa expects the amount as a decimal string ("1990.00"); our Money is in minor units,
|
|
9
|
+
// so we divide by 100 (RUB and most currencies have 2 decimal places).
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import type { CreateCheckoutArgs, NormalizedPaymentEvent, PaymentProvider, PaymentWebhookRequest } from '@vitrine-kit/core';
|
|
12
|
+
|
|
13
|
+
const API = 'https://api.yookassa.ru/v3/payments';
|
|
14
|
+
const ZERO_DECIMAL = new Set(['JPY', 'KRW', 'VND', 'CLP']);
|
|
15
|
+
|
|
16
|
+
/** Minor units → currency decimal string ("199000" RUB → "1990.00"). */
|
|
17
|
+
function minorToDecimalString(amount: number, currency: string): string {
|
|
18
|
+
return ZERO_DECIMAL.has(currency.toUpperCase()) ? String(amount) : (amount / 100).toFixed(2);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function authHeader(): string {
|
|
22
|
+
const shopId = process.env.YOOKASSA_SHOP_ID ?? '';
|
|
23
|
+
const secret = process.env.YOOKASSA_SECRET_KEY ?? '';
|
|
24
|
+
return `Basic ${Buffer.from(`${shopId}:${secret}`).toString('base64')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const yookassaProvider: PaymentProvider = {
|
|
28
|
+
name: 'yookassa',
|
|
29
|
+
|
|
30
|
+
async createCheckout(args: CreateCheckoutArgs): Promise<{ redirectUrl: string }> {
|
|
31
|
+
const { cart, baseUrl, successPath = '/order/success', cancelPath = '/cart' } = args;
|
|
32
|
+
const res = await fetch(API, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: authHeader(),
|
|
36
|
+
'Idempotence-Key': randomUUID(),
|
|
37
|
+
'Content-Type': 'application/json',
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
amount: {
|
|
41
|
+
value: minorToDecimalString(cart.total, cart.currency),
|
|
42
|
+
currency: cart.currency.toUpperCase(),
|
|
43
|
+
},
|
|
44
|
+
capture: true,
|
|
45
|
+
confirmation: { type: 'redirect', return_url: `${baseUrl}${successPath}` },
|
|
46
|
+
description: `Order (cart ${cart.id})`,
|
|
47
|
+
metadata: { cartId: cart.id },
|
|
48
|
+
}),
|
|
49
|
+
});
|
|
50
|
+
if (!res.ok) throw new Error(`[vitrine] YooKassa: ${res.status} ${await res.text()}`);
|
|
51
|
+
const payment = (await res.json()) as { confirmation?: { confirmation_url?: string } };
|
|
52
|
+
return { redirectUrl: payment.confirmation?.confirmation_url ?? `${baseUrl}${cancelPath}` };
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async verifyWebhook(req: PaymentWebhookRequest): Promise<NormalizedPaymentEvent> {
|
|
56
|
+
const body = JSON.parse(req.rawBody) as { event?: string; object?: { id?: string } };
|
|
57
|
+
const paymentId = body.object?.id;
|
|
58
|
+
if (!paymentId) return { kind: 'unknown', raw: body };
|
|
59
|
+
|
|
60
|
+
// The notification is unsigned — re-check the payment via the API.
|
|
61
|
+
const res = await fetch(`${API}/${paymentId}`, { headers: { Authorization: authHeader() } });
|
|
62
|
+
if (!res.ok) throw new Error(`[vitrine] YooKassa verify: ${res.status}`);
|
|
63
|
+
const payment = (await res.json()) as {
|
|
64
|
+
id?: string;
|
|
65
|
+
status?: string;
|
|
66
|
+
metadata?: { cartId?: string };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (payment.status === 'succeeded') {
|
|
70
|
+
return {
|
|
71
|
+
kind: 'checkout_completed',
|
|
72
|
+
cartId: payment.metadata?.cartId,
|
|
73
|
+
providerRef: payment.id,
|
|
74
|
+
raw: payment,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (payment.status === 'canceled') {
|
|
78
|
+
return { kind: 'payment_failed', raw: payment };
|
|
79
|
+
}
|
|
80
|
+
return { kind: 'unknown', raw: payment };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Registers the YooKassa payment provider. The CLI calls this from lib/payments.ts.
|
|
2
|
+
import { payments } from '@vitrine-kit/core';
|
|
3
|
+
import { yookassaProvider } from './provider.js';
|
|
4
|
+
|
|
5
|
+
export function registerCheckoutYookassaProvider(): void {
|
|
6
|
+
payments.register(yookassaProvider);
|
|
7
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Feature: product-page
|
|
2
|
+
|
|
3
|
+
The product detail page. Depends on `catalog` (registryDependencies).
|
|
4
|
+
|
|
5
|
+
## Components
|
|
6
|
+
- `components/product/ProductView` — the detail card. **Hosts the slots** `product.gallery`, `product.below-title`, `product.below-price`, `product.purchase`, `product.below-description`, `product.tabs` — other features (reviews, cart, wishlist) mount here.
|
|
7
|
+
- `components/product/ProductGallery` — the image gallery.
|
|
8
|
+
|
|
9
|
+
## Data
|
|
10
|
+
- `lib/product/data` — `loadProduct(source, slug)` over `CatalogSource.getProduct`.
|
|
11
|
+
|
|
12
|
+
## Slots
|
|
13
|
+
The feature doesn't register slots, it **provides** them: so adding reviews/cart doesn't require editing `ProductView`.
|
|
14
|
+
|
|
15
|
+
## Contracts
|
|
16
|
+
Only `@vitrine-kit/contracts` (types) and `@vitrine-kit/core/react` (`<Slot>`).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "product-page",
|
|
4
|
+
"title": "Product page",
|
|
5
|
+
"kitVersion": "0.4.0",
|
|
6
|
+
"requiresContracts": ">=1.0.0 <2.0.0",
|
|
7
|
+
"tier": [
|
|
8
|
+
"catalog",
|
|
9
|
+
"simple-store",
|
|
10
|
+
"full-store"
|
|
11
|
+
],
|
|
12
|
+
"registryDependencies": [
|
|
13
|
+
"catalog"
|
|
14
|
+
],
|
|
15
|
+
"corePackages": {
|
|
16
|
+
"@vitrine-kit/core": ">=0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"npm": [],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"from": "files/components/product/",
|
|
22
|
+
"to": "components/product/"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"from": "files/lib/product/",
|
|
26
|
+
"to": "lib/product/"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"config": {
|
|
30
|
+
"set": {
|
|
31
|
+
"features.product-page": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"slots": [],
|
|
35
|
+
"env": [],
|
|
36
|
+
"claudeDoc": "docs/product-page.md",
|
|
37
|
+
"conflicts": [],
|
|
38
|
+
"removable": false
|
|
39
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Product gallery — presentational, neutral.
|
|
2
|
+
import type { ProductImage } from '@vitrine-kit/contracts';
|
|
3
|
+
|
|
4
|
+
export interface ProductGalleryProps {
|
|
5
|
+
images: ProductImage[];
|
|
6
|
+
title: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function ProductGallery({ images, title }: ProductGalleryProps) {
|
|
10
|
+
const cover = images[0];
|
|
11
|
+
if (!cover) {
|
|
12
|
+
return <div className="aspect-square w-full rounded-md bg-muted" aria-hidden="true" />;
|
|
13
|
+
}
|
|
14
|
+
const rest = images.slice(1);
|
|
15
|
+
return (
|
|
16
|
+
<div className="vt-product-gallery">
|
|
17
|
+
<img
|
|
18
|
+
src={cover.url}
|
|
19
|
+
alt={cover.alt ?? title}
|
|
20
|
+
className="w-full rounded-md object-cover"
|
|
21
|
+
/>
|
|
22
|
+
{rest.length > 0 ? (
|
|
23
|
+
<ul role="list" className="mt-gutter flex gap-gutter">
|
|
24
|
+
{rest.map((img, i) => (
|
|
25
|
+
<li key={img.url}>
|
|
26
|
+
<img
|
|
27
|
+
src={img.url}
|
|
28
|
+
alt={img.alt ?? `${title} — image ${i + 2}`}
|
|
29
|
+
className="h-16 w-16 rounded-md object-cover"
|
|
30
|
+
/>
|
|
31
|
+
</li>
|
|
32
|
+
))}
|
|
33
|
+
</ul>
|
|
34
|
+
) : null}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Product detail card. HOSTS the product.* slots — other features (reviews,
|
|
2
|
+
// cart, wishlist) mount into them without editing this component.
|
|
3
|
+
import type { Product } from '@vitrine-kit/contracts';
|
|
4
|
+
import { Slot } from '@vitrine-kit/core/react';
|
|
5
|
+
import { ProductGallery } from './ProductGallery.js';
|
|
6
|
+
import { formatPrice } from '../../lib/product/data.js';
|
|
7
|
+
|
|
8
|
+
export interface ProductViewProps {
|
|
9
|
+
product: Product;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ProductView({ product }: ProductViewProps) {
|
|
13
|
+
const price = product.priceRange?.min ?? product.variants[0]?.price;
|
|
14
|
+
const currency = product.priceRange?.currency ?? product.variants[0]?.currency ?? 'USD';
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<article className="vt-product-view grid gap-section md:grid-cols-2">
|
|
18
|
+
<div>
|
|
19
|
+
<Slot
|
|
20
|
+
name="product.gallery"
|
|
21
|
+
fallback={<ProductGallery images={product.images} title={product.title} />}
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="flex flex-col gap-gutter">
|
|
25
|
+
<Slot name="product.below-title" />
|
|
26
|
+
<h1 className="font-heading text-fg">{product.title}</h1>
|
|
27
|
+
{price != null ? (
|
|
28
|
+
<p className="text-price text-xl">{formatPrice(price, currency)}</p>
|
|
29
|
+
) : null}
|
|
30
|
+
<Slot name="product.below-price" />
|
|
31
|
+
<div className="vt-product-purchase">
|
|
32
|
+
<Slot name="product.purchase" product={product} />
|
|
33
|
+
</div>
|
|
34
|
+
{product.description ? (
|
|
35
|
+
<div className="vt-product-description text-fg">{product.description}</div>
|
|
36
|
+
) : null}
|
|
37
|
+
<Slot name="product.below-description" />
|
|
38
|
+
<Slot name="product.tabs" />
|
|
39
|
+
</div>
|
|
40
|
+
</article>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CatalogSource, Product } from '@vitrine-kit/contracts';
|
|
2
|
+
|
|
3
|
+
export async function loadProduct(source: CatalogSource, slug: string): Promise<Product | null> {
|
|
4
|
+
return source.getProduct(slug);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Money — minor units (e.g. cents). */
|
|
8
|
+
export function formatPrice(amount: number, currency: string, locale = 'en-US'): string {
|
|
9
|
+
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(amount / 100);
|
|
10
|
+
}
|