>
>({});
- // Hydrate state from initialValues once per product in EDIT mode.
- // In edit: slug must come from DB and stay stable (no title->slug regeneration).
useEffect(() => {
if (mode !== 'edit') {
hydratedKeyRef.current = null;
@@ -221,8 +219,6 @@ export function ProductForm({
if (hydratedKeyRef.current === key) return;
- // Reset transient UI state when switching between products in EDIT mode.
- // Do NOT do this in submit: it breaks retries (e.g., clears selected image).
setError(null);
setSlugError(null);
setImageError(null);
@@ -251,8 +247,7 @@ export function ProductForm({
}, [mode, initialValues, productId]);
const slugValue = useMemo(() => {
- if (mode === 'edit') return slug; // slug в edit має бути стабільним (з БД)
- // In create mode, always derive from current title to avoid stale slug on fast submit.
+ if (mode === 'edit') return slug;
return localSlugify(title);
}, [mode, slug, title]);
@@ -967,8 +962,8 @@ export function ProductForm({
? 'Creating...'
: 'Updating...'
: mode === 'create'
- ? 'Create product'
- : 'Save changes'}
+ ? 'Create product'
+ : 'Save changes'}
diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx
index acf8dc50..9fb12085 100644
--- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx
+++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx
@@ -422,7 +422,6 @@ export default function CartPage() {
{t('checkout.message')}
- {/* Fallback CTA if navigation fails after order was created */}
{createdOrderId && !checkoutError ? (
DO NOT prefix locale manually.
- * - Stripe return_url is an external redirect -> MUST include locale exactly once.
- */
const IN_APP_SHOP_BASE = '/shop';
function normalizeLocale(raw: string): string {
@@ -73,13 +72,21 @@ function buildInAppPath(path: string): string {
return `${IN_APP_SHOP_BASE}${p}`;
}
-function buildStripeReturnUrl(params: { locale: string; inAppPath: string }): string {
+function buildStripeReturnUrl(params: {
+ locale: string;
+ inAppPath: string;
+}): string {
const loc = normalizeLocale(params.locale);
- const p = params.inAppPath.startsWith('/') ? params.inAppPath : `/${params.inAppPath}`;
+ const p = params.inAppPath.startsWith('/')
+ ? params.inAppPath
+ : `/${params.inAppPath}`;
return new URL(`/${loc}${p}`, window.location.origin).toString();
}
-function nextRouteForPaymentResult(params: { orderId: string; status?: string | null }) {
+function nextRouteForPaymentResult(params: {
+ orderId: string;
+ status?: string | null;
+}) {
const { orderId, status } = params;
const id = encodeURIComponent(orderId);
@@ -88,7 +95,11 @@ function nextRouteForPaymentResult(params: { orderId: string; status?: string |
if (!status) return success;
- if (status === 'succeeded' || status === 'processing' || status === 'requires_capture') {
+ if (
+ status === 'succeeded' ||
+ status === 'processing' ||
+ status === 'requires_capture'
+ ) {
return success;
}
@@ -99,7 +110,6 @@ function nextRouteForPaymentResult(params: { orderId: string; status?: string |
return success;
}
-/** Unified CTA (hero) */
const SHOP_HERO_CTA = cn(
SHOP_CTA_BASE,
SHOP_CTA_INTERACTIVE,
@@ -110,7 +120,6 @@ const SHOP_HERO_CTA = cn(
'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
);
-/** Unified outline */
const SHOP_OUTLINE = cn(
SHOP_OUTLINE_BTN_BASE,
SHOP_OUTLINE_BTN_INTERACTIVE,
@@ -122,19 +131,22 @@ const SHOP_OUTLINE = cn(
function HeroCtaInner({ children }: { children: React.ReactNode }) {
return (
<>
- {/* base gradient */}
- {/* hover wave overlay */}
- {/* glass inset */}
{children}
@@ -155,7 +167,9 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
setErrorMessage(null);
if (!stripe || !elements) {
- setErrorMessage('Payment is not ready yet. Please try again in a moment.');
+ setErrorMessage(
+ 'Payment is not ready yet. Please try again in a moment.'
+ );
return;
}
@@ -190,14 +204,20 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
} catch (error) {
logError('stripe_payment_confirm_failed', error, { orderId });
setErrorMessage('We couldn’t confirm your payment. Please try again.');
- router.push(buildInAppPath(`/checkout/error?orderId=${encodeURIComponent(orderId)}`));
+ router.push(
+ buildInAppPath(`/checkout/error?orderId=${encodeURIComponent(orderId)}`)
+ );
} finally {
setSubmitting(false);
}
}
return (
-
-
{uiCurrency}
+
+ {uiCurrency}
+
diff --git a/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx b/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
index 182dc50d..76f1139c 100644
--- a/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
+++ b/frontend/app/[locale]/shop/checkout/payment/[orderId]/page.tsx
@@ -97,7 +97,6 @@ function HeroCtaLink({
}) {
return (
- {/* base gradient */}
- {/* glass inset */}
{children}
diff --git a/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx b/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx
index 909bd0f2..10c22774 100644
--- a/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx
+++ b/frontend/app/[locale]/shop/checkout/success/OrderStatusAutoRefresh.tsx
@@ -38,6 +38,5 @@ export default function OrderStatusAutoRefresh({
return () => window.clearInterval(id);
}, [paymentStatus, router, maxMs, intervalMs]);
- // Non-visual utility component (keeps page data fresh while payment settles).
return ;
}
diff --git a/frontend/app/[locale]/shop/checkout/success/page.tsx b/frontend/app/[locale]/shop/checkout/success/page.tsx
index 06813c66..91f93241 100644
--- a/frontend/app/[locale]/shop/checkout/success/page.tsx
+++ b/frontend/app/[locale]/shop/checkout/success/page.tsx
@@ -67,7 +67,6 @@ const SHOP_HERO_CTA_SM = cn(
'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
);
-/** Outline secondary action (Link) */
const SHOP_OUTLINE_BTN = cn(
SHOP_OUTLINE_BTN_BASE,
SHOP_OUTLINE_BTN_INTERACTIVE,
@@ -77,7 +76,6 @@ const SHOP_OUTLINE_BTN = cn(
function HeroCtaInner({ children }: { children: React.ReactNode }) {
return (
<>
- {/* base gradient */}
- {/* hover wave overlay */}
- {/* glass inset */}
{children}
@@ -210,7 +206,6 @@ export default async function CheckoutSuccessPage({
>
- {/* auto-refresh while webhook finalizes */}
diff --git a/frontend/app/[locale]/shop/orders/[id]/page.tsx b/frontend/app/[locale]/shop/orders/[id]/page.tsx
index 97c8397d..97dcedad 100644
--- a/frontend/app/[locale]/shop/orders/[id]/page.tsx
+++ b/frontend/app/[locale]/shop/orders/[id]/page.tsx
@@ -175,7 +175,6 @@ export default async function OrderDetailPage({
.where(whereClause)
.orderBy(orderItems.id);
- // non-admin: "не існує" == "не твій"
if (rows.length === 0) notFound();
const base = rows[0]!.order;
diff --git a/frontend/app/[locale]/shop/orders/page.tsx b/frontend/app/[locale]/shop/orders/page.tsx
index fe7de800..6be669a3 100644
--- a/frontend/app/[locale]/shop/orders/page.tsx
+++ b/frontend/app/[locale]/shop/orders/page.tsx
@@ -186,11 +186,8 @@ export default async function MyOrdersPage({
logError('My orders page failed', error);
throw new Error('MY_ORDERS_LOAD_FAILED');
}
- // Nav/breadcrumb-ish links ("Back to shop", "Browse products")
const NAV_LINK = cn(SHOP_NAV_LINK_BASE, 'text-lg', SHOP_FOCUS);
- // Order headline link in the table: make it match cart product link style
- // (cart uses: cn('block truncate', SHOP_LINK_BASE, SHOP_LINK_MD, SHOP_FOCUS))
const ORDER_HEADLINE_LINK = cn(
'block max-w-[24rem] truncate',
SHOP_LINK_BASE,
diff --git a/frontend/app/[locale]/shop/page.tsx b/frontend/app/[locale]/shop/page.tsx
index 99a6ea77..a86ddfb8 100644
--- a/frontend/app/[locale]/shop/page.tsx
+++ b/frontend/app/[locale]/shop/page.tsx
@@ -15,7 +15,8 @@ import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Shop | DevLovers',
- description: 'DevLovers merch shop — browse products, add to cart, and checkout.',
+ description:
+ 'DevLovers merch shop — browse products, add to cart, and checkout.',
};
export default async function HomePage({
@@ -136,21 +137,18 @@ export default async function HomePage({
`}
aria-label={t('hero.cta')}
>
- {/* base gradient */}
- {/* hover wave overlay */}
- {/* glass inset */}
diff --git a/frontend/app/[locale]/shop/products/[slug]/page.tsx b/frontend/app/[locale]/shop/products/[slug]/page.tsx
index 953d41f0..f5ea2ed8 100644
--- a/frontend/app/[locale]/shop/products/[slug]/page.tsx
+++ b/frontend/app/[locale]/shop/products/[slug]/page.tsx
@@ -26,10 +26,6 @@ export default async function ProductPage({
const t = await getTranslations('shop.products');
const tProduct = await getTranslations('shop.product');
- // P0-5 canonical gate:
- // - slug AND is_active=true
- // - join product_prices by currency
- // - missing price -> 404 (public hides existence/details)
const currency = resolveCurrencyFromLocale(locale);
const publicProduct = await getPublicProductBySlug(slug, currency);
if (!publicProduct) {
@@ -42,8 +38,13 @@ export default async function ProductPage({
notFound();
}
const isUnavailable = result.kind === 'unavailable';
- const product = result.product as any; // shape differs for unavailable vs available; guard reads below
- const NAV_LINK = cn(SHOP_NAV_LINK_BASE, SHOP_FOCUS, 'text-lg', 'items-center gap-2');
+ const product = result.product as any;
+ const NAV_LINK = cn(
+ SHOP_NAV_LINK_BASE,
+ SHOP_FOCUS,
+ 'text-lg',
+ 'items-center gap-2'
+ );
const badge = product?.badge as string | undefined;
const badgeLabel =
badge && badge !== 'NONE'
diff --git a/frontend/app/[locale]/shop/products/page.tsx b/frontend/app/[locale]/shop/products/page.tsx
index b02e6cae..8757db69 100644
--- a/frontend/app/[locale]/shop/products/page.tsx
+++ b/frontend/app/[locale]/shop/products/page.tsx
@@ -36,7 +36,6 @@ export default async function ProductsPage({
const resolvedSearchParams = (await searchParams) ?? {};
const t = await getTranslations('shop.products');
- // canonicalize: infinite-load page should not be shareable as ?page=N
if (resolvedSearchParams.page) {
const qsParams = new URLSearchParams();
diff --git a/frontend/app/api/ai/explain/route.ts b/frontend/app/api/ai/explain/route.ts
index f821c4ad..e12c8e1e 100644
--- a/frontend/app/api/ai/explain/route.ts
+++ b/frontend/app/api/ai/explain/route.ts
@@ -9,7 +9,6 @@ import {
} from '@/lib/ai/prompts';
import { getCurrentUser } from '@/lib/auth';
-
const rateLimiter = new Map();
const MAX_REQUESTS_PER_WINDOW = 10;
const RATE_LIMIT_WINDOW_MS = 20 * 60 * 1000;
@@ -40,8 +39,11 @@ const requestSchema = z.object({
.optional(),
});
-
-function checkRateLimit(userId: string): { allowed: boolean; remaining: number; resetIn: number } {
+function checkRateLimit(userId: string): {
+ allowed: boolean;
+ remaining: number;
+ resetIn: number;
+} {
cleanupRateLimiter();
const now = Date.now();
@@ -49,7 +51,11 @@ function checkRateLimit(userId: string): { allowed: boolean; remaining: number;
if (!entry || now > entry.resetAt) {
rateLimiter.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
- return { allowed: true, remaining: MAX_REQUESTS_PER_WINDOW - 1, resetIn: RATE_LIMIT_WINDOW_MS };
+ return {
+ allowed: true,
+ remaining: MAX_REQUESTS_PER_WINDOW - 1,
+ resetIn: RATE_LIMIT_WINDOW_MS,
+ };
}
if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
@@ -130,7 +136,6 @@ export async function POST(request: Request) {
);
}
- // Parse and validate request body
let body: unknown;
try {
body = await request.json();
@@ -155,7 +160,6 @@ export async function POST(request: Request) {
const { term, context } = validationResult.data;
- // Initialize Groq client
const groq = new Groq({ apiKey });
try {
@@ -194,15 +198,16 @@ export async function POST(request: Request) {
}
}
-// =============================================================================
-// GET /api/ai/explain - Health check
-// =============================================================================
export async function GET() {
const hasApiKey = !!process.env.GROQ_API_KEY;
if (!hasApiKey) {
return NextResponse.json(
- { status: 'error', service: 'ai-explain', message: 'API key not configured' },
+ {
+ status: 'error',
+ service: 'ai-explain',
+ message: 'API key not configured',
+ },
{ status: 503 }
);
}
@@ -213,13 +218,11 @@ export async function GET() {
);
}
-// =============================================================================
-// Error Handling
-// =============================================================================
function handleGroqError(error: unknown): NextResponse {
- // Handle Groq SDK specific errors
if (error instanceof Groq.APIError) {
- console.error(`[ai/explain] Groq API error: ${error.status} ${error.message}`);
+ console.error(
+ `[ai/explain] Groq API error: ${error.status} ${error.message}`
+ );
if (error.status === 401) {
return NextResponse.json(
@@ -242,14 +245,12 @@ function handleGroqError(error: unknown): NextResponse {
);
}
- // Other API errors (500, 503, etc.)
return NextResponse.json(
{ error: 'AI service temporarily unavailable', code: 'API_ERROR' },
{ status: 503 }
);
}
- // Handle JSON parse errors from response parsing
if (error instanceof SyntaxError) {
console.error('[ai/explain] Failed to parse AI response as JSON');
return NextResponse.json(
@@ -258,8 +259,10 @@ function handleGroqError(error: unknown): NextResponse {
);
}
- // Handle response structure validation errors
- if (error instanceof Error && error.message === 'Invalid response structure') {
+ if (
+ error instanceof Error &&
+ error.message === 'Invalid response structure'
+ ) {
console.error('[ai/explain] AI response missing required fields');
return NextResponse.json(
{ error: 'AI returned incomplete response', code: 'INVALID_STRUCTURE' },
@@ -267,7 +270,6 @@ function handleGroqError(error: unknown): NextResponse {
);
}
- // Unknown errors
console.error('[ai/explain] Unexpected error:', error);
return NextResponse.json(
{ error: 'Failed to generate explanation', code: 'AI_ERROR' },
diff --git a/frontend/app/api/shop/admin/orders/[id]/route.ts b/frontend/app/api/shop/admin/orders/[id]/route.ts
index 82b37feb..9c075567 100644
--- a/frontend/app/api/shop/admin/orders/[id]/route.ts
+++ b/frontend/app/api/shop/admin/orders/[id]/route.ts
@@ -31,9 +31,6 @@ export async function GET(
const requestId =
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();
- // Origin posture: same-origin enforcement is applied to mutating methods;
- // GET is intentionally unguarded.
-
const baseMeta = {
requestId,
route: request.nextUrl.pathname,
@@ -45,9 +42,6 @@ export async function GET(
try {
await requireAdminApi(request);
- // CSRF is enforced only for state-changing admin routes.
- // This endpoint is read-only (GET), so we intentionally do not require CSRF.
-
const rawParams = await context.params;
const parsed = orderIdParamSchema.safeParse(rawParams);
diff --git a/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts b/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts
index 7c19087d..ca45e0db 100644
--- a/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts
+++ b/frontend/app/api/shop/admin/orders/reconcile-stale/route.ts
@@ -30,13 +30,6 @@ export async function POST(request: NextRequest) {
const requestId =
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();
- // NOTE: We intentionally keep TWO origin checks:
- // 1) guardBrowserSameOrigin(): generic unsafe-request Origin allowlist gate
- // (APP_ORIGIN/APP_ADDITIONAL_ORIGINS), fail-fast before auth/body parsing.
- // 2) isSameOrigin(): CSRF-specific strict same-origin assertion, so CSRF origin mismatch
- // is logged/coded separately.
- // These checks are not equivalent and serve different error semantics (policy vs CSRF).
-
const blocked = guardBrowserSameOrigin(request);
if (blocked) {
@@ -58,10 +51,8 @@ export async function POST(request: NextRequest) {
};
try {
- // 1) Kill-switch + auth FIRST (no body parsing)
await requireAdminApi(request);
- // 2) CSRF checks second (browser-only semantics)
if (!isSameOrigin(request)) {
logWarn('admin_reconcile_stale_csrf_origin_mismatch', {
...baseMeta,
diff --git a/frontend/app/api/shop/admin/products/[id]/route.ts b/frontend/app/api/shop/admin/products/[id]/route.ts
index a3dd66d9..e889d186 100644
--- a/frontend/app/api/shop/admin/products/[id]/route.ts
+++ b/frontend/app/api/shop/admin/products/[id]/route.ts
@@ -179,9 +179,6 @@ export async function GET(
const requestId =
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();
- // Origin posture: same-origin enforcement is applied to mutating methods;
- // GET is intentionally unguarded.
-
const baseMeta = {
requestId,
route: request.nextUrl.pathname,
@@ -423,7 +420,6 @@ export async function PATCH(
{ status: 400 }
);
}
- // SALE invariant is validated on parsed/normalized payload (single source of truth).
const saleViolation = findSaleRuleViolation(parsed.data);
if (saleViolation) {
const message =
@@ -467,7 +463,7 @@ export async function PATCH(
if (error instanceof PriceConfigError) {
logWarn('admin_product_update_price_config_error', {
...baseMeta,
- code: error.code, // PRICE_CONFIG_ERROR
+ code: error.code,
productId: productIdForLog,
currency: error.currency,
durationMs: Date.now() - startedAtMs,
@@ -650,7 +646,6 @@ export async function DELETE(
try {
await requireAdminApi(request);
- // DELETE can’t reliably carry FormData; CSRF is validated via header/cookie path inside requireAdminCsrf.
const csrfRes = requireAdminCsrf(request, 'admin:products:delete');
if (csrfRes) {
logWarn('admin_product_delete_csrf_rejected', {
@@ -686,10 +681,8 @@ export async function DELETE(
}
productIdForLog = parsedParams.data.id;
- // Fail fast: do not attempt DELETE if product is referenced by orders.
- const blockerConstraint = await getProductDeleteBlockerConstraint(
- productIdForLog
- );
+ const blockerConstraint =
+ await getProductDeleteBlockerConstraint(productIdForLog);
if (blockerConstraint) {
logWarn('admin_product_delete_in_use', {
...baseMeta,
@@ -759,7 +752,6 @@ export async function DELETE(
}
const { code: pgCode, constraint } = getPgMeta(error);
- // Postgres: 23503 = foreign_key_violation
if (pgCode === '23503') {
logWarn('admin_product_delete_in_use', {
...baseMeta,
diff --git a/frontend/app/api/shop/admin/products/[id]/status/route.ts b/frontend/app/api/shop/admin/products/[id]/status/route.ts
index 4d7ccd9e..3120080a 100644
--- a/frontend/app/api/shop/admin/products/[id]/status/route.ts
+++ b/frontend/app/api/shop/admin/products/[id]/status/route.ts
@@ -92,8 +92,6 @@ export async function PATCH(
const updated = await toggleProductStatus(productIdForLog);
- // no success log (avoid log noise); rely on response + metrics
-
return noStoreJson({ success: true, product: updated }, { status: 200 });
} catch (error) {
if (error instanceof AdminApiDisabledError) {
diff --git a/frontend/app/api/shop/cart/rehydrate/route.ts b/frontend/app/api/shop/cart/rehydrate/route.ts
index 45484f3c..41849b55 100644
--- a/frontend/app/api/shop/cart/rehydrate/route.ts
+++ b/frontend/app/api/shop/cart/rehydrate/route.ts
@@ -97,7 +97,6 @@ export async function POST(request: NextRequest) {
const { items } = parsedPayload.data;
const parsedResult = await rehydrateCartItems(items, currency);
- // Success signal (avoid noise on empty carts)
if (Array.isArray(items) && items.length > 0) {
logInfo('cart_rehydrate_completed', {
...meta,
@@ -109,7 +108,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json(parsedResult);
} catch (error) {
- // Missing price for locale currency is a CONTRACT error (4xx), but must be traceable.
if (error instanceof PriceConfigError) {
logWarn('cart_rehydrate_price_config_error', {
...meta,
@@ -124,7 +122,6 @@ export async function POST(request: NextRequest) {
});
}
- // Client/business rejection (4xx) must be traceable as warn (not error).
if (error instanceof InvalidPayloadError) {
logWarn('cart_rehydrate_rejected', {
...meta,
@@ -134,7 +131,6 @@ export async function POST(request: NextRequest) {
return jsonError(400, error.code, error.message);
}
- // DB misconfiguration / invalid stored money => 500 with stable code.
if (error instanceof MoneyValueError) {
logError('cart_rehydrate_price_data_error', error, {
...meta,
diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts
index 37b06916..30c3e7e2 100644
--- a/frontend/app/api/shop/checkout/route.ts
+++ b/frontend/app/api/shop/checkout/route.ts
@@ -154,7 +154,6 @@ async function readJsonBody(request: NextRequest): Promise {
throw new Error('EMPTY_BODY');
}
- // tolerate BOM / odd whitespace
const normalized = raw.replace(/^\uFEFF/, '');
return JSON.parse(normalized);
@@ -224,8 +223,7 @@ export async function POST(request: NextRequest) {
idempotencyKey.format?.()
);
}
- // For observability: shorten to avoid oversized structured logs.
- // Never used as an idempotency key; the full header value remains canonical.
+
const idempotencyKeyShort = idempotencyKey.slice(0, 32);
const meta = {
@@ -299,8 +297,7 @@ export async function POST(request: NextRequest) {
);
}
}
- // P1: rate limit checkout (cross-instance, DB-backed)
- // Policy: allow reasonable retries; block abusive burst.
+
const checkoutSubject = sessionUserId ?? getRateLimitSubject(request);
const limitParsed = Number.parseInt(
@@ -418,9 +415,6 @@ export async function POST(request: NextRequest) {
const stripePaymentFlow =
paymentsEnabled && order.paymentProvider === 'stripe';
- // =========================
- // Existing order path
- // =========================
if (!result.isNew) {
if (stripePaymentFlow) {
try {
@@ -444,7 +438,6 @@ export async function POST(request: NextRequest) {
});
} catch (error) {
if (error instanceof PaymentAttemptsExhaustedError) {
- // Best-effort release to avoid holding reserved stock indefinitely.
try {
await restockOrder(order.id, { reason: 'failed' });
} catch (restockError) {
@@ -468,7 +461,6 @@ export async function POST(request: NextRequest) {
);
}
- // Post-create/state conflict must be 409 (not 502)
if (error instanceof InvalidPayloadError) {
logWarn('checkout_conflict', {
...orderMeta,
@@ -509,7 +501,6 @@ export async function POST(request: NextRequest) {
}
}
- // Not Stripe flow => return existing order as-is
return buildCheckoutResponse({
order: {
id: order.id,
@@ -525,9 +516,6 @@ export async function POST(request: NextRequest) {
});
}
- // =========================
- // New order path
- // =========================
if (!stripePaymentFlow) {
return buildCheckoutResponse({
order: {
@@ -544,7 +532,6 @@ export async function POST(request: NextRequest) {
});
}
- // Stripe new order: durable attempt layer (bounded + audited)
try {
const ensured = await ensureStripePaymentIntentForOrder({
orderId: order.id,
@@ -565,7 +552,6 @@ export async function POST(request: NextRequest) {
status: 201,
});
} catch (error) {
- // Conflict => 409 and DO NOT restock (leave reserved; retry/janitor)
if (error instanceof InvalidPayloadError) {
logWarn('checkout_conflict', {
...orderMeta,
@@ -581,7 +567,6 @@ export async function POST(request: NextRequest) {
);
}
if (error instanceof PaymentAttemptsExhaustedError) {
- // Best-effort release to avoid holding reserved stock indefinitely
try {
await restockOrder(order.id, { reason: 'failed' });
} catch (restockError) {
diff --git a/frontend/app/api/shop/internal/orders/restock-stale/route.ts b/frontend/app/api/shop/internal/orders/restock-stale/route.ts
index d8ae44f3..66fb43b8 100644
--- a/frontend/app/api/shop/internal/orders/restock-stale/route.ts
+++ b/frontend/app/api/shop/internal/orders/restock-stale/route.ts
@@ -36,17 +36,14 @@ const DEFAULT_POLICY: SweepPolicy = {
const BATCH_MIN = 25;
const BATCH_MAX = 100;
-const MIN_MINUTES = 10; // NOT 0
+const MIN_MINUTES = 10;
const MAX_MINUTES = 60 * 24 * 7;
-// 9.1: minimal max runtime
const DEFAULT_MAX_RUNTIME_MS = 20_000;
const MAX_RUNTIME_MIN_MS = 1_000;
const MAX_RUNTIME_MAX_MS = 25_000;
-// 9.2: min interval is enforced via ENV (prod 300; dev 60)
-// request can only INCREASE it, never decrease below env.
-const DEFAULT_REQUESTED_MIN_INTERVAL_SECONDS = 1; // lets env be the real floor
+const DEFAULT_REQUESTED_MIN_INTERVAL_SECONDS = 1;
const MIN_INTERVAL_SECONDS_MIN = 1;
const MIN_INTERVAL_SECONDS_MAX = 60 * 60;
@@ -90,7 +87,6 @@ function parseOlderThanPolicy(
req: NextRequest,
body: unknown
): OlderThanPolicy | { error: string } {
- // Legacy: ?olderThanMinutes=60 (applies to stalePending only)
const qLegacy = req.nextUrl.searchParams.get('olderThanMinutes');
if (qLegacy !== null && qLegacy !== undefined) {
const n = toFiniteNumber(qLegacy);
@@ -102,7 +98,6 @@ function parseOlderThanPolicy(
};
}
- // Body: olderThanMinutes can be number (legacy) or object (new)
let candidate: unknown = null;
if (body && typeof body === 'object' && 'olderThanMinutes' in body) {
candidate = (body as Record).olderThanMinutes;
@@ -110,7 +105,6 @@ function parseOlderThanPolicy(
return DEFAULT_POLICY.olderThanMinutes;
}
- // Legacy body: { olderThanMinutes: 60 } -> stalePending only
if (typeof candidate === 'number' || typeof candidate === 'string') {
const n = toFiniteNumber(candidate);
if (n === null) return { error: 'olderThanMinutes must be a number' };
@@ -121,7 +115,6 @@ function parseOlderThanPolicy(
};
}
- // New body: { olderThanMinutes: { stuckReserving, stalePending, orphanNoPayment } }
if (!candidate || typeof candidate !== 'object') {
return { error: 'olderThanMinutes must be a number or an object' };
}
@@ -197,7 +190,6 @@ function parseRequestedMinIntervalSeconds(
}
function getEnvMinIntervalSeconds(): number {
- // tests must not be slowed down / flake because of persistent DB limiter
if (process.env.NODE_ENV === 'test') return 0;
const fallback = process.env.NODE_ENV === 'production' ? 300 : 60;
@@ -216,10 +208,6 @@ function normalizeDate(x: unknown): Date | null {
return isNaN(d.getTime()) ? null : d;
}
-/**
- * DB-backed rate limiter (cross-instance).
- * Atomic: one SQL statement with ON CONFLICT ... WHERE next_allowed_at <= now()
- */
async function acquireJobSlot(params: {
jobName: string;
effectiveMinIntervalSeconds: number;
@@ -302,7 +290,6 @@ export async function POST(request: NextRequest) {
code: 'INVALID_PAYLOAD',
reason: error instanceof Error ? error.message : String(error),
});
- // ignore invalid/missing json body; query params may be used instead
}
const batchSizeParsed = parseBatchSize(request, body);
@@ -402,7 +389,6 @@ export async function POST(request: NextRequest) {
requestedMinIntervalSeconds
);
- // DB-backed limiter: cross-instance, atomic
const runId = crypto.randomUUID();
const jobName = baseMeta.jobName;
const workerId = `janitor:${runId}`;
@@ -445,7 +431,6 @@ export async function POST(request: NextRequest) {
const startedAtMs = Date.now();
const deadlineMs = startedAtMs + maxRuntimeMs;
- // 1) stuck reserving (timeout on inventory reservation phase)
const remaining0 = Math.max(0, deadlineMs - Date.now());
const processedStuckReserving = await restockStuckReservingOrders({
olderThanMinutes: policy.olderThanMinutes.stuckReserving,
@@ -454,7 +439,6 @@ export async function POST(request: NextRequest) {
timeBudgetMs: remaining0,
});
- // 2) stale pending (stripe payment never completed)
const remaining1 = Math.max(0, deadlineMs - Date.now());
const processedStalePending = await restockStalePendingOrders({
olderThanMinutes: policy.olderThanMinutes.stalePending,
@@ -462,7 +446,7 @@ export async function POST(request: NextRequest) {
workerId,
timeBudgetMs: remaining1,
});
- // 3) orphan no-payment (payments disabled flow can be "paid" but not reserved yet)
+
const remaining2 = Math.max(0, deadlineMs - Date.now());
const processedOrphanNoPayment = await restockStaleNoPaymentOrders({
olderThanMinutes: policy.olderThanMinutes.orphanNoPayment,
@@ -505,7 +489,7 @@ export async function POST(request: NextRequest) {
orphanNoPayment: processedOrphanNoPayment,
},
batchSize: policy.batchSize,
- olderThanMinutes: policy.olderThanMinutes.stalePending, // legacy field
+ olderThanMinutes: policy.olderThanMinutes.stalePending,
appliedPolicy: policy,
maxRuntimeMs,
minIntervalSeconds,
diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts
index 43e2fdfc..73671d9a 100644
--- a/frontend/app/api/shop/webhooks/stripe/route.ts
+++ b/frontend/app/api/shop/webhooks/stripe/route.ts
@@ -23,7 +23,6 @@ import { guardNonBrowserOnly } from '@/lib/security/origin';
const REFUND_FULLNESS_UNDETERMINED = 'REFUND_FULLNESS_UNDETERMINED' as const;
-// P0.8: multi-instance claim/lock (no transactions; safe under parallel deliveries)
const STRIPE_WEBHOOK_INSTANCE_ID =
(
process.env.STRIPE_WEBHOOK_INSTANCE_ID ??
@@ -31,7 +30,7 @@ const STRIPE_WEBHOOK_INSTANCE_ID =
''
).trim() || crypto.randomUUID().slice(0, 12);
-const STRIPE_EVENT_CLAIM_TTL_MS = 10 * 60 * 1000; // 10 minutes
+const STRIPE_EVENT_CLAIM_TTL_MS = 10 * 60 * 1000;
const STRIPE_EVENT_RETRY_AFTER_SECONDS = 10;
function noStoreJson(
@@ -171,7 +170,6 @@ function warnRefundFullnessUndetermined(payload: {
}) {
logWarn('stripe_webhook_refund_fullness_undetermined', {
...payload,
- // keep consistent correlation fields with stripe_webhook
stripeEventId: payload.eventId,
instanceId: STRIPE_WEBHOOK_INSTANCE_ID,
provider: 'stripe',
@@ -206,10 +204,10 @@ function logWebhookEvent(payload: {
refundId != null
? 'refund'
: chargeId != null
- ? 'charge'
- : paymentIntentId != null
- ? 'payment_intent'
- : null;
+ ? 'charge'
+ : paymentIntentId != null
+ ? 'payment_intent'
+ : null;
logInfo('stripe_webhook', {
requestId,
@@ -372,7 +370,6 @@ function mergePspMetadata(params: {
createdAtIso: params.createdAtIso,
});
- // Do NOT allow delta to overwrite refunds/refundInitiatedAt (canonical fields managed by upsertRefundIntoMeta)
const safeDelta: any = { ...cleanedDelta };
delete safeDelta.refunds;
delete safeDelta.refundInitiatedAt;
@@ -386,8 +383,6 @@ function shouldRestockFromWebhook(order: {
stockRestored: boolean | null;
inventoryStatus: string | null;
}) {
- // Webhook-level gate: avoid unnecessary calls when we already restored stock
- // or inventory has already been released.
if (order.stockRestored === true) return false;
if (order.inventoryStatus === 'released') return false;
return true;
@@ -547,7 +542,6 @@ export async function POST(request: NextRequest) {
if (eventType.startsWith('payment_intent.')) {
paymentIntent = rawObject as Stripe.PaymentIntent;
} else if (eventType === 'charge.refund.updated') {
- // Stripe sends Refund object for charge.refund.updated
refundObject = rawObject as Stripe.Refund;
} else if (eventType.startsWith('charge.')) {
charge = rawObject as Stripe.Charge;
@@ -596,7 +590,7 @@ export async function POST(request: NextRequest) {
const bestEffortChargeId: string | null = paymentIntent
? getLatestChargeId(paymentIntent)
- : charge?.id ?? bestEffortRefundChargeId ?? null;
+ : (charge?.id ?? bestEffortRefundChargeId ?? null);
const bestEffortRefundId: string | null = refundObject?.id ?? null;
@@ -654,7 +648,6 @@ export async function POST(request: NextRequest) {
return noStoreJson({ received: true }, { status: 200 });
};
- // 1) Insert event idempotently (no transactions)
const inserted = await db
.insert(stripeEvents)
.values({
@@ -684,9 +677,8 @@ export async function POST(request: NextRequest) {
return noStoreJson({ received: true }, { status: 200 });
}
- // processedAt is NULL => previous attempt failed; reprocess
}
- // P0.8: claim/lock BEFORE any business logic (multi-instance safe).
+
const claimState = await tryClaimStripeEvent(event.id);
if (claimState === 'already_processed') {
logInfo('stripe_webhook_duplicate_event', {
@@ -704,9 +696,7 @@ export async function POST(request: NextRequest) {
});
return busyRetry();
}
- //2) Resolve orderId:
- // primary: metadata.orderId
- // fallback: orders.paymentIntentId == paymentIntentId (ONLY if unique match)
+
let resolvedOrderId: string | undefined = orderId;
if (!resolvedOrderId) {
@@ -745,7 +735,6 @@ export async function POST(request: NextRequest) {
}
}
- // backfill stripe_events.orderId if resolved via fallback (or to normalize)
if (resolvedOrderId && !orderId) {
await db
.update(stripeEvents)
@@ -753,7 +742,6 @@ export async function POST(request: NextRequest) {
.where(eq(stripeEvents.eventId, event.id));
}
- // 3) Load order
const [order] = await db
.select({
id: orders.id,
@@ -798,7 +786,6 @@ export async function POST(request: NextRequest) {
return ack();
}
- // 4) Business logic per event type
if (eventType === 'payment_intent.succeeded') {
const stripeAmount =
paymentIntent?.amount_received ?? paymentIntent?.amount ?? null;
@@ -814,8 +801,8 @@ export async function POST(request: NextRequest) {
!amountMatches && !currencyMatches
? 'amount_and_currency_mismatch'
: !amountMatches
- ? 'amount_mismatch'
- : 'currency_mismatch';
+ ? 'amount_mismatch'
+ : 'currency_mismatch';
const chargeForIntent = getLatestCharge(paymentIntent as any);
const latestChargeId = getLatestChargeId(paymentIntent);
@@ -833,7 +820,6 @@ export async function POST(request: NextRequest) {
currency: order.currency,
},
actual: { amountMinor: stripeAmount, currency: stripeCurrency },
- // keep old fields for backward-compat/debug grepping
stripeAmount,
orderAmountMinor,
stripeCurrency,
@@ -923,9 +909,7 @@ export async function POST(request: NextRequest) {
isNull(orders.inventoryStatus),
ne(orders.inventoryStatus, 'released')
),
- // avoid churn when already consistent
or(ne(orders.paymentStatus, 'paid'), ne(orders.status, 'PAID')),
- // explicit safety gates (redundant with matrix, but keep)
ne(orders.paymentStatus, 'failed'),
ne(orders.paymentStatus, 'refunded')
),
@@ -1139,12 +1123,11 @@ export async function POST(request: NextRequest) {
? refund.charge
: null
: refund && typeof refund.charge === 'object' && refund.charge
- ? typeof (refund.charge as any).id === 'string'
- ? (refund.charge as any).id
- : null
- : null;
+ ? typeof (refund.charge as any).id === 'string'
+ ? (refund.charge as any).id
+ : null
+ : null;
- // MVP: only FULL refund.
let isFullRefund = false;
if (eventType === 'charge.refunded') {
@@ -1225,7 +1208,8 @@ export async function POST(request: NextRequest) {
const hasCurrent =
refund?.id && list.some(r => r?.id && r.id === refund.id);
- cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt ?? 0);
+ cumulativeRefunded =
+ sumFromList + (hasCurrent ? 0 : (currentAmt ?? 0));
}
if (amt == null || cumulativeRefunded == null) {
@@ -1346,7 +1330,8 @@ export async function POST(request: NextRequest) {
}
const hasCurrent = list.some(r => r?.id && r.id === refund.id);
- cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt ?? 0);
+ cumulativeRefunded =
+ sumFromList + (hasCurrent ? 0 : (currentAmt ?? 0));
}
if (amt == null || cumulativeRefunded == null) {
@@ -1421,7 +1406,6 @@ export async function POST(request: NextRequest) {
.set({
updatedAt: now,
pspMetadata: nextMeta,
- // do NOT change paymentStatus/status for partial refund
pspChargeId: charge?.id ?? refundChargeId ?? null,
pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge),
pspStatusReason: 'PARTIAL_REFUND_IGNORED',
@@ -1466,7 +1450,7 @@ export async function POST(request: NextRequest) {
note: eventType,
set: {
updatedAt: now,
- status: 'CANCELED', // terminal in current enum
+ status: 'CANCELED',
pspChargeId: charge?.id ?? refundChargeId ?? null,
pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge),
pspStatusReason: refund?.reason ?? refund?.status ?? 'refunded',
@@ -1496,7 +1480,6 @@ export async function POST(request: NextRequest) {
return ack();
}
- // default ack
logWebhookEvent({
requestId,
stripeEventId,
@@ -1511,7 +1494,6 @@ export async function POST(request: NextRequest) {
return ack();
} catch (error) {
if (isRefundFullnessUndeterminedError(error)) {
- // Do NOT ack() -> keep processedAt NULL so Stripe retries.
return noStoreJson(
{ code: REFUND_FULLNESS_UNDETERMINED },
{ status: 500 }
@@ -1525,7 +1507,6 @@ export async function POST(request: NextRequest) {
orderId: orderId ?? null,
});
- // P0.8: release claim early so Stripe retries can be claimed immediately.
try {
await db
.update(stripeEvents)
diff --git a/frontend/checkout.json b/frontend/checkout.json
deleted file mode 100644
index 999c4d97..00000000
--- a/frontend/checkout.json
+++ /dev/null
@@ -1 +0,0 @@
-{"items":[{"productId":"dcefd1da-567a-4a5c-b6aa-7cd1a85319cc","quantity":1}]}
\ No newline at end of file
diff --git a/frontend/components/q&a/AIWordHelper.tsx b/frontend/components/q&a/AIWordHelper.tsx
index 59b7e55d..a860ecd2 100644
--- a/frontend/components/q&a/AIWordHelper.tsx
+++ b/frontend/components/q&a/AIWordHelper.tsx
@@ -3,7 +3,21 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation';
-import { X, Loader2, RefreshCw, Sparkles, GripHorizontal, Clock, Coffee, CloudOff, Wrench, Star, Github, Heart, BookOpen } from 'lucide-react';
+import {
+ X,
+ Loader2,
+ RefreshCw,
+ Sparkles,
+ GripHorizontal,
+ Clock,
+ Coffee,
+ CloudOff,
+ Wrench,
+ Star,
+ Github,
+ Heart,
+ BookOpen,
+} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Link } from '@/i18n/routing';
import {
@@ -35,7 +49,7 @@ interface DragState {
interface RateLimitState {
isRateLimited: boolean;
- resetIn: number; // milliseconds
+ resetIn: number;
retryAttempts: number;
}
@@ -55,7 +69,9 @@ const SUPPORTED_LOCALES: Locale[] = ['uk', 'en', 'pl'];
const DEFAULT_LOCALE: Locale = 'en';
function isValidLocale(value: unknown): value is Locale {
- return typeof value === 'string' && SUPPORTED_LOCALES.includes(value as Locale);
+ return (
+ typeof value === 'string' && SUPPORTED_LOCALES.includes(value as Locale)
+ );
}
function getValidLocale(value: unknown): Locale {
@@ -89,7 +105,7 @@ function formatExplanation(text: string): React.ReactNode {
const flushCode = () => {
if (codeBuffer.length > 0) {
const code = codeBuffer.join('\n').trim();
-
+
if (code && code.length > 0) {
result.push(