@vitrine-kit/vitrine 0.4.1 → 0.5.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/README.md +1 -1
- package/dist/index.js +114 -44
- package/kit/registry/_index.json +70 -9
- package/kit/registry/accounts/docs/accounts.md +20 -0
- package/kit/registry/accounts/feature.json +55 -0
- package/kit/registry/accounts/files/app/(frontend)/account/forgot-password/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/login/page.tsx +29 -0
- package/kit/registry/accounts/files/app/(frontend)/account/orders/page.tsx +90 -0
- package/kit/registry/accounts/files/app/(frontend)/account/page.tsx +86 -0
- package/kit/registry/accounts/files/app/(frontend)/account/register/page.tsx +24 -0
- package/kit/registry/accounts/files/app/(frontend)/account/reset-password/page.tsx +25 -0
- package/kit/registry/accounts/files/app/api/account/orders/route.ts +39 -0
- package/kit/registry/accounts/files/components/accounts/AccountLink.tsx +15 -0
- package/kit/registry/accounts/files/components/accounts/ForgotPasswordForm.tsx +90 -0
- package/kit/registry/accounts/files/components/accounts/LoginForm.tsx +84 -0
- package/kit/registry/accounts/files/components/accounts/LogoutButton.tsx +31 -0
- package/kit/registry/accounts/files/components/accounts/RegisterForm.tsx +108 -0
- package/kit/registry/accounts/files/components/accounts/ResetPasswordForm.tsx +84 -0
- package/kit/registry/accounts/files/lib/accounts/blueprint.ts +47 -0
- package/kit/registry/accounts/files/lib/accounts/register.ts +6 -0
- package/kit/registry/accounts/files/lib/accounts/session.ts +18 -0
- package/kit/registry/cart/feature.json +1 -1
- package/kit/registry/cart/files/app/api/cart/route.ts +101 -11
- package/kit/registry/cart/files/components/cart/AddToCart.tsx +142 -15
- package/kit/registry/cart/files/components/cart/CartIndicator.tsx +27 -2
- package/kit/registry/cart/files/components/cart/CartLineItem.tsx +71 -13
- package/kit/registry/cart/files/components/cart/CartView.tsx +21 -1
- package/kit/registry/catalog/feature.json +1 -1
- package/kit/registry/catalog/files/components/catalog/ProductCard.tsx +5 -1
- package/kit/registry/catalog/files/lib/catalog/data.ts +73 -5
- package/kit/registry/checkout/docs/checkout.md +19 -1
- package/kit/registry/checkout/feature.json +1 -1
- package/kit/registry/checkout/files/app/(frontend)/order/success/page.tsx +49 -0
- package/kit/registry/checkout/files/app/api/checkout/route.ts +25 -4
- package/kit/registry/checkout/files/components/checkout/CheckoutButton.tsx +5 -3
- package/kit/registry/checkout/files/lib/checkout/fulfill.ts +22 -0
- package/kit/registry/checkout/files/lib/checkout/notify.ts +52 -0
- package/kit/registry/checkout-paddle/feature.json +1 -1
- package/kit/registry/checkout-paddle/files/app/api/webhooks/paddle/route.ts +9 -2
- package/kit/registry/checkout-stripe/feature.json +1 -1
- package/kit/registry/checkout-stripe/files/app/api/webhooks/stripe/route.ts +9 -2
- package/kit/registry/checkout-yookassa/feature.json +1 -1
- package/kit/registry/checkout-yookassa/files/app/api/webhooks/yookassa/route.ts +9 -2
- package/kit/registry/email/docs/email.md +19 -0
- package/kit/registry/email/feature.json +60 -0
- package/kit/registry/email/files/lib/email/adapter.ts +89 -0
- package/kit/registry/filters/docs/filters.md +13 -0
- package/kit/registry/filters/feature.json +45 -0
- package/kit/registry/filters/files/components/filters/CatalogToolbar.tsx +123 -0
- package/kit/registry/filters/files/lib/filters/register.ts +6 -0
- package/kit/registry/i18n/docs/i18n.md +24 -0
- package/kit/registry/i18n/feature.json +47 -0
- package/kit/registry/i18n/files/components/i18n/LocaleSwitcher.tsx +64 -0
- package/kit/registry/i18n/files/lib/i18n/dictionary.ts +52 -0
- package/kit/registry/i18n/files/lib/i18n/locale.ts +11 -0
- package/kit/registry/i18n/files/lib/i18n/register.ts +6 -0
- package/kit/registry/i18n/files/lib/i18n/t.ts +1 -0
- package/kit/registry/i18n/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/registry/i18n/files/middleware.ts +51 -0
- package/kit/registry/product-page/feature.json +1 -1
- package/kit/registry/product-page/files/components/product/ProductGallery.tsx +23 -10
- package/kit/registry/product-page/files/components/product/ProductView.tsx +8 -4
- package/kit/registry/reviews/docs/reviews.md +11 -0
- package/kit/registry/reviews/feature.json +55 -0
- package/kit/registry/reviews/files/app/api/reviews/route.ts +55 -0
- package/kit/registry/reviews/files/components/reviews/ReviewList.tsx +119 -0
- package/kit/registry/reviews/files/lib/reviews/blueprint.ts +13 -0
- package/kit/registry/reviews/files/lib/reviews/register.ts +6 -0
- package/kit/registry/reviews/files/lib/reviews/types.ts +22 -0
- package/kit/registry/search/docs/search.md +12 -0
- package/kit/registry/search/feature.json +49 -0
- package/kit/registry/search/files/app/(frontend)/search/page.tsx +34 -0
- package/kit/registry/search/files/components/search/SearchForm.tsx +32 -0
- package/kit/registry/search/files/components/search/SearchResults.tsx +87 -0
- package/kit/registry/search/files/lib/search/data.ts +8 -0
- package/kit/registry/search/files/lib/search/register.ts +8 -0
- package/kit/registry/seo/docs/seo.md +4 -1
- package/kit/registry/seo/feature.json +5 -1
- package/kit/registry/seo/files/app/robots.ts +13 -0
- package/kit/registry/seo/files/app/sitemap.ts +27 -0
- package/kit/registry/stubs/route-glue.d.ts +71 -0
- package/kit/registry/tsconfig.json +7 -2
- package/kit/registry/wishlist/docs/wishlist.md +11 -0
- package/kit/registry/wishlist/feature.json +55 -0
- package/kit/registry/wishlist/files/app/(frontend)/wishlist/page.tsx +16 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistButton.tsx +37 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistIndicator.tsx +30 -0
- package/kit/registry/wishlist/files/components/wishlist/WishlistView.tsx +73 -0
- package/kit/registry/wishlist/files/lib/wishlist/register.ts +8 -0
- package/kit/registry/wishlist/files/lib/wishlist/storage.ts +30 -0
- package/kit/templates/backend-payload/files/Dockerfile +3 -0
- package/kit/templates/backend-payload/files/docker-compose.yml +8 -0
- package/kit/templates/backend-payload/files/lib/adapter/payload-catalog.ts +51 -17
- package/kit/templates/backend-payload/files/lib/adapter/payload-commerce.ts +18 -0
- package/kit/templates/backend-payload/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-payload/files/lib/seed/admin.ts +16 -6
- package/kit/templates/backend-payload/files/lib/seed/demo.ts +74 -28
- package/kit/templates/backend-payload/files/lib/seed/guards.ts +17 -4
- package/kit/templates/backend-payload/files/lib/seed/locales.ts +53 -0
- package/kit/templates/backend-payload/files/lib/seed/run.ts +92 -16
- package/kit/templates/backend-payload/files/next.config.mjs +34 -0
- package/kit/templates/backend-payload/files/payload.config.ts +33 -3
- package/kit/templates/backend-payload/files/seed-assets/placeholder-1b.svg +6 -0
- package/kit/templates/backend-payload/files/seed-assets/placeholder-2b.svg +6 -0
- package/kit/templates/backend-vendure/files/Dockerfile +1 -1
- package/kit/templates/backend-vendure/files/docker-compose.yml +2 -1
- package/kit/templates/backend-vendure/files/lib/adapter/product-query.ts +126 -0
- package/kit/templates/backend-vendure/files/lib/adapter/vendure-catalog.ts +18 -11
- package/kit/templates/backend-vendure/files/vendure-config.ts +38 -1
- package/kit/templates/base/files/.claude/commands/update.md +1 -1
- package/kit/templates/base/files/app/(frontend)/categories/[slug]/page.tsx +57 -5
- package/kit/templates/base/files/app/(frontend)/layout.tsx +13 -7
- package/kit/templates/base/files/app/(frontend)/not-found.tsx +26 -0
- package/kit/templates/base/files/app/(frontend)/page.tsx +84 -6
- package/kit/templates/base/files/app/(frontend)/products/[slug]/page.tsx +5 -2
- package/kit/templates/base/files/app/globals.css +13 -0
- package/kit/templates/base/files/components/SlotsProvider.tsx +19 -0
- package/kit/templates/base/files/components/layout/SiteFooter.tsx +6 -1
- package/kit/templates/base/files/components/layout/SiteHeader.tsx +8 -2
- package/kit/templates/base/files/lib/i18n/dictionary.ts +36 -0
- package/kit/templates/base/files/lib/i18n/locale.ts +11 -0
- package/kit/templates/base/files/lib/i18n/useChromeLabel.ts +28 -0
- package/kit/templates/base/files/lib/site.ts +3 -0
- package/kit/templates/base/files/next.config.mjs +34 -0
- package/package.json +2 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Post-payment landing page. Stripe/Paddle/YooKassa redirect here after a successful
|
|
2
|
+
// hosted checkout (successPath default: /order/success). Order fulfillment happens
|
|
3
|
+
// asynchronously via the provider webhook — this page is confirmation UX only.
|
|
4
|
+
import type { Metadata } from 'next';
|
|
5
|
+
import { cookies } from 'next/headers';
|
|
6
|
+
import { Slot } from '@vitrine-kit/core/react';
|
|
7
|
+
import { siteName } from '@/lib/site';
|
|
8
|
+
import { t } from '@/lib/i18n/dictionary';
|
|
9
|
+
|
|
10
|
+
export const metadata: Metadata = {
|
|
11
|
+
title: 'Order confirmed',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface PageProps {
|
|
15
|
+
searchParams: Promise<{ session_id?: string }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default async function OrderSuccessPage({ searchParams }: PageProps) {
|
|
19
|
+
const params = await searchParams;
|
|
20
|
+
const sessionId = params.session_id;
|
|
21
|
+
const locale = (await cookies()).get('vitrine_locale')?.value ?? 'en';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="vt-order-success flex flex-col gap-section">
|
|
25
|
+
<Slot name="order.top" />
|
|
26
|
+
<section aria-labelledby="order-success-heading" className="flex flex-col gap-gutter">
|
|
27
|
+
<h1 id="order-success-heading" className="font-heading text-3xl text-fg">
|
|
28
|
+
{t('thankYou', locale)}
|
|
29
|
+
</h1>
|
|
30
|
+
<p className="max-w-prose text-muted-fg">
|
|
31
|
+
Your payment was received. {siteName} will email a confirmation when the order is
|
|
32
|
+
recorded{sessionId ? ' (this can take a few seconds while the payment webhook runs)' : ''}.
|
|
33
|
+
</p>
|
|
34
|
+
{sessionId ? (
|
|
35
|
+
<p className="text-sm text-muted-fg">
|
|
36
|
+
Reference: <span className="font-mono text-fg">{sessionId}</span>
|
|
37
|
+
</p>
|
|
38
|
+
) : null}
|
|
39
|
+
<a
|
|
40
|
+
href="/"
|
|
41
|
+
className="w-fit rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
42
|
+
>
|
|
43
|
+
{t('continueShopping', locale)}
|
|
44
|
+
</a>
|
|
45
|
+
</section>
|
|
46
|
+
<Slot name="order.below" />
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -3,12 +3,33 @@
|
|
|
3
3
|
// redirect URL. Provider-agnostic: startCheckout delegates to payments.resolve. Next glue.
|
|
4
4
|
import { cookies } from 'next/headers';
|
|
5
5
|
import { NextResponse } from 'next/server';
|
|
6
|
+
import { checkRateLimit, clientIpFromHeaders } from '@vitrine-kit/core';
|
|
6
7
|
import { getCommerceBackend } from '@/lib/adapter';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
const RATE_LIMIT = { limit: 10, windowMs: 60_000 };
|
|
10
|
+
|
|
11
|
+
function publicError(error: unknown): string {
|
|
12
|
+
if (!(error instanceof Error)) return 'Checkout is unavailable — try again later.';
|
|
13
|
+
return error.message.replace(/^\[vitrine\]\s*/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function POST(req: Request) {
|
|
17
|
+
const { allowed, retryAfterMs } = checkRateLimit(`checkout:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
18
|
+
if (!allowed) {
|
|
19
|
+
return NextResponse.json(
|
|
20
|
+
{ error: 'too many requests' },
|
|
21
|
+
{ status: 429, headers: { 'Retry-After': String(Math.ceil((retryAfterMs ?? 0) / 1000)) } },
|
|
22
|
+
);
|
|
23
|
+
}
|
|
9
24
|
const cartId = (await cookies()).get('vitrine_cart')?.value;
|
|
10
25
|
if (!cartId) return NextResponse.json({ error: 'cart is empty' }, { status: 400 });
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
try {
|
|
27
|
+
const commerce = await getCommerceBackend();
|
|
28
|
+
const { redirectUrl } = await commerce.startCheckout(cartId);
|
|
29
|
+
return NextResponse.json({ url: redirectUrl });
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const message = publicError(error);
|
|
32
|
+
const status = /is not set|empty/i.test(message) ? 503 : 500;
|
|
33
|
+
return NextResponse.json({ error: message }, { status });
|
|
34
|
+
}
|
|
14
35
|
}
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
// redirect URL — the component doesn't know which provider is configured (Stripe/Paddle/YooKassa).
|
|
4
4
|
'use client';
|
|
5
5
|
import { useState } from 'react';
|
|
6
|
+
import { useChromeLabel } from '@/lib/i18n/useChromeLabel';
|
|
6
7
|
|
|
7
8
|
export function CheckoutButton() {
|
|
8
9
|
const [pending, setPending] = useState(false);
|
|
9
10
|
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
const checkout = useChromeLabel('checkout');
|
|
10
12
|
|
|
11
|
-
async function
|
|
13
|
+
async function startCheckout(): Promise<void> {
|
|
12
14
|
setPending(true);
|
|
13
15
|
setError(null);
|
|
14
16
|
try {
|
|
@@ -30,11 +32,11 @@ export function CheckoutButton() {
|
|
|
30
32
|
<>
|
|
31
33
|
<button
|
|
32
34
|
type="button"
|
|
33
|
-
onClick={
|
|
35
|
+
onClick={startCheckout}
|
|
34
36
|
disabled={pending}
|
|
35
37
|
className="vt-checkout-button rounded-md bg-primary px-gutter py-unit text-primary-fg transition hover:opacity-90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 ring-ring"
|
|
36
38
|
>
|
|
37
|
-
{pending ? 'Redirecting to payment…' :
|
|
39
|
+
{pending ? 'Redirecting to payment…' : checkout}
|
|
38
40
|
</button>
|
|
39
41
|
{error ? (
|
|
40
42
|
<p role="alert" className="vt-checkout-error text-danger">
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type NormalizedPaymentEvent,
|
|
13
13
|
type PaymentProviderName,
|
|
14
14
|
} from '@vitrine-kit/core';
|
|
15
|
+
import { notifyOrderConfirmation } from './notify.js';
|
|
15
16
|
|
|
16
17
|
/** Creates an order from the cart on a payment event and marks the cart converted. */
|
|
17
18
|
export async function fulfillOrderFromEvent(
|
|
@@ -50,6 +51,22 @@ export async function fulfillOrderFromEvent(
|
|
|
50
51
|
};
|
|
51
52
|
const order = buildOrderFromCart(cart, { id: cartId, email: event.email });
|
|
52
53
|
|
|
54
|
+
// When the accounts feature is installed, attach the matching customer by checkout email.
|
|
55
|
+
let customerId: string | undefined;
|
|
56
|
+
const email = event.email?.trim().toLowerCase();
|
|
57
|
+
if (email) {
|
|
58
|
+
const customers = await payload
|
|
59
|
+
.find({
|
|
60
|
+
collection: 'customers',
|
|
61
|
+
where: { email: { equals: email } },
|
|
62
|
+
limit: 1,
|
|
63
|
+
overrideAccess: true,
|
|
64
|
+
})
|
|
65
|
+
.catch(() => null);
|
|
66
|
+
const doc = customers?.docs[0];
|
|
67
|
+
if (doc) customerId = String(doc.id);
|
|
68
|
+
}
|
|
69
|
+
|
|
53
70
|
await payload.create({
|
|
54
71
|
collection: 'orders',
|
|
55
72
|
data: {
|
|
@@ -62,6 +79,7 @@ export async function fulfillOrderFromEvent(
|
|
|
62
79
|
createdAt: order.createdAt,
|
|
63
80
|
paymentProvider: providerName,
|
|
64
81
|
paymentRef: event.providerRef,
|
|
82
|
+
...(customerId ? { customer: customerId } : {}),
|
|
65
83
|
},
|
|
66
84
|
});
|
|
67
85
|
await payload.update({
|
|
@@ -69,4 +87,8 @@ export async function fulfillOrderFromEvent(
|
|
|
69
87
|
id: cartId,
|
|
70
88
|
data: { status: 'converted', paymentRef: event.providerRef },
|
|
71
89
|
});
|
|
90
|
+
|
|
91
|
+
await notifyOrderConfirmation(order).catch((err) => {
|
|
92
|
+
console.warn('[vitrine] order confirmation notify failed', err);
|
|
93
|
+
});
|
|
72
94
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Order confirmation email. Uses Payload sendEmail when the email feature + SMTP
|
|
2
|
+
// are configured; otherwise logs to the console (zero-config).
|
|
3
|
+
import type { Order } from '@vitrine-kit/contracts';
|
|
4
|
+
import { siteConfig } from '@/site.config';
|
|
5
|
+
import { siteName } from '@/lib/site';
|
|
6
|
+
|
|
7
|
+
export async function notifyOrderConfirmation(order: Order): Promise<void> {
|
|
8
|
+
const to = order.email?.trim();
|
|
9
|
+
if (!to) return;
|
|
10
|
+
|
|
11
|
+
const provider = siteConfig.integrations.email;
|
|
12
|
+
const subject = `Order confirmation — ${siteName}`;
|
|
13
|
+
const body = [
|
|
14
|
+
`Thanks for your order at ${siteName}.`,
|
|
15
|
+
``,
|
|
16
|
+
`Order total: ${(order.total / 100).toFixed(2)} ${order.currency}`,
|
|
17
|
+
`Status: ${order.status}`,
|
|
18
|
+
`Lines: ${order.lines.map((l) => `${l.quantity}× ${l.title}`).join(', ')}`,
|
|
19
|
+
].join('\n');
|
|
20
|
+
|
|
21
|
+
let from: string | undefined;
|
|
22
|
+
try {
|
|
23
|
+
const emailMod = await import('../email/adapter.js');
|
|
24
|
+
from = emailMod.emailFromAddress();
|
|
25
|
+
} catch {
|
|
26
|
+
// email feature not installed
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (provider || from) {
|
|
30
|
+
try {
|
|
31
|
+
const { getPayload } = await import('payload');
|
|
32
|
+
const config = (await import('@payload-config')).default;
|
|
33
|
+
const payload = await getPayload({ config });
|
|
34
|
+
await payload.sendEmail({
|
|
35
|
+
to,
|
|
36
|
+
from: from ?? undefined,
|
|
37
|
+
subject,
|
|
38
|
+
text: body,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.warn(
|
|
43
|
+
'[vitrine] payload.sendEmail failed — falling back to console log',
|
|
44
|
+
error instanceof Error ? error.message : error,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.info(
|
|
50
|
+
`[vitrine] order email${provider ? ` via "${provider}"` : ''}${from ? ` from ${from}` : ''} → ${to}\n${subject}\n${body}`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// Paddle webhook: paddleProvider.verifyWebhook (Paddle-Signature) →
|
|
2
2
|
// normalized event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
|
|
3
3
|
import { NextResponse } from 'next/server';
|
|
4
|
-
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
4
|
+
import { checkRateLimit, clientIpFromHeaders, handlePaymentWebhook } from '@vitrine-kit/core';
|
|
5
5
|
import { paddleProvider } from '@/lib/checkout-paddle/provider';
|
|
6
6
|
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
7
7
|
|
|
8
|
+
const RATE_LIMIT = { limit: 120, windowMs: 60_000 };
|
|
9
|
+
|
|
8
10
|
export async function POST(req: Request) {
|
|
11
|
+
const { allowed } = checkRateLimit(`webhook:paddle:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
12
|
+
if (!allowed) return NextResponse.json({ error: 'too many requests' }, { status: 429 });
|
|
13
|
+
|
|
9
14
|
const rawBody = await req.text();
|
|
10
15
|
const headers = { 'paddle-signature': req.headers.get('paddle-signature') };
|
|
11
16
|
|
|
@@ -17,6 +22,8 @@ export async function POST(req: Request) {
|
|
|
17
22
|
});
|
|
18
23
|
return NextResponse.json(result);
|
|
19
24
|
} catch (err) {
|
|
20
|
-
|
|
25
|
+
// Don't echo provider-specific error text (e.g. signature failure detail) to the caller.
|
|
26
|
+
console.error('[vitrine] paddle webhook error:', err);
|
|
27
|
+
return NextResponse.json({ error: 'invalid webhook' }, { status: 400 });
|
|
21
28
|
}
|
|
22
29
|
}
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
// event → handlePaymentWebhook → the shared fulfillOrderFromEvent creates an order from
|
|
3
3
|
// the cart and marks it converted. No provider-specific code here — just glue.
|
|
4
4
|
import { NextResponse } from 'next/server';
|
|
5
|
-
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
5
|
+
import { checkRateLimit, clientIpFromHeaders, handlePaymentWebhook } from '@vitrine-kit/core';
|
|
6
6
|
import { stripeProvider } from '@/lib/checkout-stripe/provider';
|
|
7
7
|
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
8
8
|
|
|
9
|
+
const RATE_LIMIT = { limit: 120, windowMs: 60_000 };
|
|
10
|
+
|
|
9
11
|
export async function POST(req: Request) {
|
|
12
|
+
const { allowed } = checkRateLimit(`webhook:stripe:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
13
|
+
if (!allowed) return NextResponse.json({ error: 'too many requests' }, { status: 429 });
|
|
14
|
+
|
|
10
15
|
const rawBody = await req.text();
|
|
11
16
|
const headers = { 'stripe-signature': req.headers.get('stripe-signature') };
|
|
12
17
|
|
|
@@ -18,6 +23,8 @@ export async function POST(req: Request) {
|
|
|
18
23
|
});
|
|
19
24
|
return NextResponse.json(result);
|
|
20
25
|
} catch (err) {
|
|
21
|
-
|
|
26
|
+
// Don't echo provider-specific error text (e.g. signature failure detail) to the caller.
|
|
27
|
+
console.error('[vitrine] stripe webhook error:', err);
|
|
28
|
+
return NextResponse.json({ error: 'invalid webhook' }, { status: 400 });
|
|
22
29
|
}
|
|
23
30
|
}
|
|
@@ -2,11 +2,16 @@
|
|
|
2
2
|
// re-checks the payment via the API (we trust only succeeded) → normalized
|
|
3
3
|
// event → handlePaymentWebhook → the shared fulfillOrderFromEvent.
|
|
4
4
|
import { NextResponse } from 'next/server';
|
|
5
|
-
import { handlePaymentWebhook } from '@vitrine-kit/core';
|
|
5
|
+
import { checkRateLimit, clientIpFromHeaders, handlePaymentWebhook } from '@vitrine-kit/core';
|
|
6
6
|
import { yookassaProvider } from '@/lib/checkout-yookassa/provider';
|
|
7
7
|
import { fulfillOrderFromEvent } from '@/lib/checkout/fulfill';
|
|
8
8
|
|
|
9
|
+
const RATE_LIMIT = { limit: 120, windowMs: 60_000 };
|
|
10
|
+
|
|
9
11
|
export async function POST(req: Request) {
|
|
12
|
+
const { allowed } = checkRateLimit(`webhook:yookassa:${clientIpFromHeaders(req.headers)}`, RATE_LIMIT);
|
|
13
|
+
if (!allowed) return NextResponse.json({ error: 'too many requests' }, { status: 429 });
|
|
14
|
+
|
|
10
15
|
const rawBody = await req.text();
|
|
11
16
|
|
|
12
17
|
try {
|
|
@@ -17,6 +22,8 @@ export async function POST(req: Request) {
|
|
|
17
22
|
});
|
|
18
23
|
return NextResponse.json(result);
|
|
19
24
|
} catch (err) {
|
|
20
|
-
|
|
25
|
+
// Don't echo provider-specific error text (e.g. re-confirmation failure detail) to the caller.
|
|
26
|
+
console.error('[vitrine] yookassa webhook error:', err);
|
|
27
|
+
return NextResponse.json({ error: 'invalid webhook' }, { status: 400 });
|
|
21
28
|
}
|
|
22
29
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Feature: email
|
|
2
|
+
|
|
3
|
+
Wires Payload's email transport for order confirmations and auth mail.
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
- `lib/email/adapter.ts` — `resolveEmailAdapter()` builds `@payloadcms/email-nodemailer`
|
|
7
|
+
from `SMTP_*` / `EMAIL_FROM`, or a **console adapter** when `EMAIL_FROM` is set without
|
|
8
|
+
`SMTP_HOST` (reset links and order mail print to the terminal / `docker compose logs`)
|
|
9
|
+
- `payload.config` (template) awaits the adapter when the file is present
|
|
10
|
+
- Checkout `notifyOrderConfirmation` calls `payload.sendEmail` when an adapter is configured
|
|
11
|
+
|
|
12
|
+
## Setup
|
|
13
|
+
1. `vitrine add email` (installs `@payloadcms/email-nodemailer` + `nodemailer`)
|
|
14
|
+
2. Set `EMAIL_FROM` (required for any adapter). Optionally `SMTP_HOST` / `SMTP_PORT` /
|
|
15
|
+
`SMTP_USER` / `SMTP_PASS` / `EMAIL_FROM_NAME` for real delivery
|
|
16
|
+
3. `integrations.email` is set to `"smtp"` by the feature config
|
|
17
|
+
|
|
18
|
+
Without `SMTP_HOST`, zero-config still works — messages (including password-reset links)
|
|
19
|
+
are logged to the console via the console adapter.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "email",
|
|
4
|
+
"title": "Transactional email",
|
|
5
|
+
"kitVersion": "0.5.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
|
+
"npm": [
|
|
16
|
+
"@payloadcms/email-nodemailer@^3.0.0",
|
|
17
|
+
"nodemailer@^6.9.0"
|
|
18
|
+
],
|
|
19
|
+
"files": [
|
|
20
|
+
{
|
|
21
|
+
"from": "files/lib/email/",
|
|
22
|
+
"to": "lib/email/"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"config": {
|
|
26
|
+
"set": {
|
|
27
|
+
"features.email": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"slots": [],
|
|
31
|
+
"env": [
|
|
32
|
+
{
|
|
33
|
+
"key": "EMAIL_FROM",
|
|
34
|
+
"required": false
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"key": "EMAIL_FROM_NAME",
|
|
38
|
+
"required": false
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"key": "SMTP_HOST",
|
|
42
|
+
"required": false
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"key": "SMTP_PORT",
|
|
46
|
+
"required": false
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"key": "SMTP_USER",
|
|
50
|
+
"required": false
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"key": "SMTP_PASS",
|
|
54
|
+
"required": false
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"claudeDoc": "docs/email.md",
|
|
58
|
+
"conflicts": [],
|
|
59
|
+
"removable": true
|
|
60
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Avoid importing `payload` types here — registry typecheck has no Payload deps.
|
|
2
|
+
export type VitrineEmailAdapter = {
|
|
3
|
+
name: string;
|
|
4
|
+
defaultFromAddress: string;
|
|
5
|
+
defaultFromName: string;
|
|
6
|
+
sendEmail: (message: unknown) => Promise<unknown>;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type EmailMessage = {
|
|
10
|
+
to?: string | string[];
|
|
11
|
+
subject?: string;
|
|
12
|
+
html?: string;
|
|
13
|
+
text?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function emailFromAddress(): string | undefined {
|
|
17
|
+
return process.env.EMAIL_FROM?.trim() || undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emailFromName(): string {
|
|
21
|
+
return process.env.EMAIL_FROM_NAME?.trim() || 'Store';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Dev/zero-config adapter: logs the full message (incl. reset links) to the console. */
|
|
25
|
+
function consoleEmailAdapter(from: string): VitrineEmailAdapter {
|
|
26
|
+
return {
|
|
27
|
+
name: 'vitrine-console',
|
|
28
|
+
defaultFromAddress: from,
|
|
29
|
+
defaultFromName: emailFromName(),
|
|
30
|
+
async sendEmail(message: unknown) {
|
|
31
|
+
const msg = (message ?? {}) as EmailMessage;
|
|
32
|
+
const to = Array.isArray(msg.to) ? msg.to.join(', ') : (msg.to ?? '(unknown)');
|
|
33
|
+
const body = msg.text || msg.html || '';
|
|
34
|
+
console.info(
|
|
35
|
+
[
|
|
36
|
+
'[vitrine] console email (no SMTP_HOST)',
|
|
37
|
+
` from: ${from}`,
|
|
38
|
+
` to: ${to}`,
|
|
39
|
+
` subject: ${msg.subject ?? '(none)'}`,
|
|
40
|
+
' ---',
|
|
41
|
+
body,
|
|
42
|
+
' ---',
|
|
43
|
+
].join('\n'),
|
|
44
|
+
);
|
|
45
|
+
return { messageId: `console-${Date.now()}` };
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve Payload's `email` config entry. Dynamic-imports `@payloadcms/email-nodemailer`
|
|
52
|
+
* when SMTP is configured; otherwise returns a console adapter so auth reset links
|
|
53
|
+
* and order mail still surface in docker logs / the terminal.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveEmailAdapter(): Promise<VitrineEmailAdapter | undefined> {
|
|
56
|
+
const from = emailFromAddress();
|
|
57
|
+
const host = process.env.SMTP_HOST?.trim();
|
|
58
|
+
if (!from) return undefined;
|
|
59
|
+
|
|
60
|
+
if (!host) {
|
|
61
|
+
console.info(`[vitrine] EMAIL_FROM=${from} (no SMTP_HOST — console email adapter)`);
|
|
62
|
+
return consoleEmailAdapter(from);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const { nodemailerAdapter } = await import('@payloadcms/email-nodemailer');
|
|
67
|
+
const port = Number(process.env.SMTP_PORT || 587);
|
|
68
|
+
const user = process.env.SMTP_USER?.trim();
|
|
69
|
+
const pass = process.env.SMTP_PASS ?? '';
|
|
70
|
+
console.info(`[vitrine] SMTP email adapter → ${host}:${port} from ${from}`);
|
|
71
|
+
return nodemailerAdapter({
|
|
72
|
+
defaultFromAddress: from,
|
|
73
|
+
defaultFromName: emailFromName(),
|
|
74
|
+
skipVerify: process.env.NODE_ENV !== 'production',
|
|
75
|
+
transportOptions: {
|
|
76
|
+
host,
|
|
77
|
+
port,
|
|
78
|
+
secure: port === 465,
|
|
79
|
+
auth: user ? { user, pass } : undefined,
|
|
80
|
+
},
|
|
81
|
+
}) as unknown as VitrineEmailAdapter;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.warn(
|
|
84
|
+
'[vitrine] @payloadcms/email-nodemailer not installed — falling back to console email adapter',
|
|
85
|
+
error instanceof Error ? error.message : error,
|
|
86
|
+
);
|
|
87
|
+
return consoleEmailAdapter(from);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Feature: filters
|
|
2
|
+
|
|
3
|
+
Catalog toolbar for sort, option facets, and price range via the `catalog.toolbar` slot.
|
|
4
|
+
|
|
5
|
+
## Modules
|
|
6
|
+
- `components/filters/CatalogToolbar` — GET form updating `?sort=`, `?size=`, `?color=`,
|
|
7
|
+
`?priceMin=` / `?priceMax=` (major currency units) on the current listing URL
|
|
8
|
+
- Mounted into `catalog.toolbar` on home and category pages
|
|
9
|
+
|
|
10
|
+
## Contracts
|
|
11
|
+
- `ProductQuery.sort` (`newest` | `price-asc` | `price-desc` | `relevance`)
|
|
12
|
+
- `ProductQuery.filters` — option facets, e.g. `{ size: ['S'], color: ['Black'] }`
|
|
13
|
+
- `ProductQuery.priceMin` / `priceMax` — inclusive bounds in minor units (cents)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../schemas/feature.schema.json",
|
|
3
|
+
"name": "filters",
|
|
4
|
+
"title": "Catalog sort, facets, and price filters",
|
|
5
|
+
"kitVersion": "0.5.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/filters/",
|
|
22
|
+
"to": "components/filters/"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"from": "files/lib/filters/",
|
|
26
|
+
"to": "lib/filters/"
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"config": {
|
|
30
|
+
"set": {
|
|
31
|
+
"features.filters": true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"slots": [
|
|
35
|
+
{
|
|
36
|
+
"slot": "catalog.toolbar",
|
|
37
|
+
"component": "CatalogToolbar",
|
|
38
|
+
"order": 10
|
|
39
|
+
}
|
|
40
|
+
],
|
|
41
|
+
"env": [],
|
|
42
|
+
"claudeDoc": "docs/filters.md",
|
|
43
|
+
"conflicts": [],
|
|
44
|
+
"removable": true
|
|
45
|
+
}
|