diff --git a/frontend/app/[locale]/shop/admin/layout.tsx b/frontend/app/[locale]/shop/admin/layout.tsx deleted file mode 100644 index b1748c79..00000000 --- a/frontend/app/[locale]/shop/admin/layout.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import type React from 'react'; -import { Link } from '@/i18n/routing'; - -import { notFound, redirect } from 'next/navigation'; - -import { - AdminApiDisabledError, - AdminForbiddenError, - AdminUnauthorizedError, - requireAdminPage, -} from '@/lib/auth/admin'; - -export default async function ShopAdminLayout({ - children, -}: { - children: React.ReactNode; -}) { - try { - await requireAdminPage(); - } catch (err) { - if (err instanceof AdminApiDisabledError) notFound(); - if (err instanceof AdminUnauthorizedError) redirect('/login'); - if (err instanceof AdminForbiddenError) notFound(); - - throw err; - } - - return ( - <> -
-
-
- - Admin - - / - - Products - - - Orders - -
- - - Back to shop - -
-
- - {children} - - ); -} diff --git a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx index 23af1bd1..ccb5f385 100644 --- a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx @@ -9,6 +9,8 @@ import { type CurrencyCode, } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; export const dynamic = 'force-dynamic'; @@ -36,6 +38,7 @@ export default async function AdminOrderDetailPage({ }: { params: Promise<{ locale: string; id: string }>; }) { + await guardShopAdminPage(); const { locale, id } = await params; const order = await getAdminOrderDetail(id); if (!order) notFound(); @@ -46,180 +49,187 @@ export default async function AdminOrderDetailPage({ !!order.paymentIntentId; return ( -
-
-
-

Order

-

- {order.id} -

-
- -
- - Back - - - -
-
- -
-
-
Summary
-
-
-
Payment status
-
{order.paymentStatus}
-
- -
-
Total
-
- {(() => { - const c = orderCurrency(order, locale); - const totalMinor = pickMinor( - order?.totalAmountMinor, - order?.totalAmount - ); - return totalMinor === null - ? '-' - : formatMoney(totalMinor, c, locale); - })()} -
-
- -
-
Provider
-
{order.paymentProvider}
-
- -
-
Payment intent
-
- {order.paymentIntentId ?? '-'} -
-
+ <> + +
+
+
+

Order

+

+ {order.id} +

+
-
-
Idempotency key
-
- {order.idempotencyKey} -
-
-
-
+
+ + Back + -
-
- Stock / timestamps +
-
-
-
Created
-
- {formatDateTime(order.createdAt)} -
-
-
-
Updated
-
- {formatDateTime(order.updatedAt)} -
-
-
-
Stock restored
-
- {order.stockRestored ? 'Yes' : 'No'} -
-
-
-
Restocked at
-
- {formatDateTime(order.restockedAt)} -
-
-
-
-
- - - - - - - - - - - - {order.items.map(item => ( - - - - - - + + + +
+
Provider
+
{order.paymentProvider}
+
+ +
+
Payment intent
+
+ {order.paymentIntentId ?? '-'} +
+
+ +
+
Idempotency key
+
+ {order.idempotencyKey} +
+
+ + - - - ))} +
+
+ Stock / timestamps +
+
+
+
Created
+
+ {formatDateTime(order.createdAt)} +
+
+
+
Updated
+
+ {formatDateTime(order.updatedAt)} +
+
+
+
Stock restored
+
+ {order.stockRestored ? 'Yes' : 'No'} +
+
+
+
Restocked at
+
+ {formatDateTime(order.restockedAt)} +
+
+
+
+ - {order.items.length === 0 ? ( +
+
- Product - - Qty - - Unit - - Line total -
-
- {item.productTitle ?? '-'} -
-
- {item.productSlug ?? '-'} - {item.productSku ? · {item.productSku} : null} -
-
- {item.quantity} - +
+
+
Summary
+
+
+
Payment status
+
{order.paymentStatus}
+
+ +
+
Total
+
{(() => { const c = orderCurrency(order, locale); - const unitMinor = pickMinor( - item?.unitPriceMinor, - item?.unitPrice + const totalMinor = pickMinor( + order?.totalAmountMinor, + order?.totalAmount ); - return unitMinor === null + return totalMinor === null ? '-' - : formatMoney(unitMinor, c, locale); + : formatMoney(totalMinor, c, locale); })()} -
- {(() => { - const c = orderCurrency(order, locale); - const lineMinor = pickMinor( - item?.lineTotalMinor, - item?.lineTotal - ); - return lineMinor === null - ? '-' - : formatMoney(lineMinor, c, locale); - })()} -
+ - + + + + - ) : null} - -
- No items found for this order. - + Product + + Qty + + Unit + + Line total +
+ + + + {order.items.map(item => ( + + +
+ {item.productTitle ?? '-'} +
+
+ + {item.productSlug ?? '-'} + + {item.productSku ? ( + · {item.productSku} + ) : null} +
+ + + + {item.quantity} + + + + {(() => { + const c = orderCurrency(order, locale); + const unitMinor = pickMinor( + item?.unitPriceMinor, + item?.unitPrice + ); + return unitMinor === null + ? '-' + : formatMoney(unitMinor, c, locale); + })()} + + + + {(() => { + const c = orderCurrency(order, locale); + const lineMinor = pickMinor( + item?.lineTotalMinor, + item?.lineTotal + ); + return lineMinor === null + ? '-' + : formatMoney(lineMinor, c, locale); + })()} + + + ))} + + {order.items.length === 0 ? ( + + + No items found for this order. + + + ) : null} + + +
-
+ ); } diff --git a/frontend/app/[locale]/shop/admin/orders/page.tsx b/frontend/app/[locale]/shop/admin/orders/page.tsx index 014d703f..2e0cea76 100644 --- a/frontend/app/[locale]/shop/admin/orders/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/page.tsx @@ -1,13 +1,27 @@ import { Link } from '@/i18n/routing'; -import { getAdminOrdersPage } from "@/db/queries/shop/admin-orders"; -import { formatMoney, resolveCurrencyFromLocale, type CurrencyCode } from "@/lib/shop/currency"; -import { fromDbMoney } from "@/lib/shop/money"; - -export const dynamic = "force-dynamic"; +import { getAdminOrdersPage } from '@/db/queries/shop/admin-orders'; +import { + formatMoney, + resolveCurrencyFromLocale, + type CurrencyCode, +} from '@/lib/shop/currency'; +import { fromDbMoney } from '@/lib/shop/money'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { AdminPagination } from '@/components/shop/admin/admin-pagination'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; + +export const dynamic = 'force-dynamic'; + +const PAGE_SIZE = 50; + +function parsePage(input: string | undefined): number { + const n = Number.parseInt(input ?? '1', 10); + return Number.isFinite(n) && n > 0 ? n : 1; +} function pickMinor(minor: unknown, legacyMajor: unknown): number | null { - if (typeof minor === "number") return minor; + if (typeof minor === 'number') return minor; if (legacyMajor === null || legacyMajor === undefined) return null; return fromDbMoney(legacyMajor); } @@ -17,92 +31,118 @@ function orderCurrency(order: any, locale: string): CurrencyCode { } function formatDate(value: Date | null | undefined) { - if (!value) return "-"; + if (!value) return '-'; return value.toLocaleDateString(); } export default async function AdminOrdersPage({ params, + searchParams, }: { params: Promise<{ locale: string }>; + searchParams: Promise<{ page?: string }>; }) { + await guardShopAdminPage(); const { locale } = await params; - const { items } = await getAdminOrdersPage({ limit: 50, offset: 0 }); + const sp = await searchParams; - return ( -
-
-

Admin · Orders

- -
- -
-
+ const page = parsePage(sp.page); + const offset = (page - 1) * PAGE_SIZE; -
- - - - - - - - - - - - - - - {items.map((order) => ( - - - - - - - - - - - - - - - ))} + // overfetch for hasNext without COUNT + const { items: all } = await getAdminOrdersPage({ + limit: PAGE_SIZE + 1, + offset, + }); + + const hasNext = all.length > PAGE_SIZE; + const items = all.slice(0, PAGE_SIZE); - {items.length === 0 ? ( + return ( + <> + +
+
+

Admin · Orders

+ +
+ + +
+ +
+
CreatedStatusTotalItemsProviderOrder IDActions
{formatDate(order.createdAt)} - - {order.paymentStatus} - - - {(() => { - const c = orderCurrency(order, locale); - const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount); - return totalMinor === null ? "-" : formatMoney(totalMinor, c, locale); - })()} - {order.itemCount}{order.paymentProvider}{order.id} - - View - -
+ - + + + + + + + - ) : null} - -
- No orders yet. - CreatedStatusTotalItemsProviderOrder IDActions
+ + + + {items.map(order => ( + + + {formatDate(order.createdAt)} + + + + + {order.paymentStatus} + + + + + {(() => { + const c = orderCurrency(order, locale); + const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount); + return totalMinor === null ? '-' : formatMoney(totalMinor, c, locale); + })()} + + + {order.itemCount} + {order.paymentProvider} + + {order.id} + + + + View + + + + ))} + + {items.length === 0 ? ( + + + No orders yet. + + + ) : null} + + + + +
- + ); } diff --git a/frontend/app/[locale]/shop/admin/page.tsx b/frontend/app/[locale]/shop/admin/page.tsx index b9d3000d..aa508c77 100644 --- a/frontend/app/[locale]/shop/admin/page.tsx +++ b/frontend/app/[locale]/shop/admin/page.tsx @@ -1,32 +1,44 @@ import { Link } from '@/i18n/routing'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; - -export default function ShopAdminHomePage() { +export default async function ShopAdminHomePage() { + await guardShopAdminPage(); return ( -
- -

Shop Admin

-

- Administrative tools for the merch shop. -

+ <> + +
+

Shop Admin

+

+ Administrative tools for the merch shop. +

-
- -
Products
-
Create, edit, activate, feature.
- +
+ +
+ Products +
+
+ Create, edit, activate, feature. +
+ - -
Orders
-
Review and manage orders.
- + +
+ Orders +
+
+ Review and manage orders. +
+ +
-
- ) + + ); } diff --git a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx index 2c5b0ae2..686f1ab2 100644 --- a/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx @@ -1,6 +1,8 @@ import { notFound } from 'next/navigation'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; import { ProductForm } from '../../_components/product-form'; import { db } from '@/db'; @@ -24,6 +26,7 @@ export default async function EditProductPage({ }: { params: Promise<{ id: string }>; }) { + await guardShopAdminPage(); const rawParams = await params; const parsed = paramsSchema.safeParse(rawParams); if (!parsed.success) return notFound(); @@ -68,25 +71,28 @@ export default async function EditProductPage({ ]; return ( - + <> + + + ); } diff --git a/frontend/app/[locale]/shop/admin/products/new/page.tsx b/frontend/app/[locale]/shop/admin/products/new/page.tsx index 0fc79684..93904c53 100644 --- a/frontend/app/[locale]/shop/admin/products/new/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/new/page.tsx @@ -1,5 +1,14 @@ -import { ProductForm } from "../_components/product-form" +import { ProductForm } from '../_components/product-form'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; -export default function NewProductPage() { - return +export default async function NewProductPage() { + await guardShopAdminPage(); + + return ( + <> + + + + ); } diff --git a/frontend/app/[locale]/shop/admin/products/page.tsx b/frontend/app/[locale]/shop/admin/products/page.tsx index 9df8d86d..94f29c2e 100644 --- a/frontend/app/[locale]/shop/admin/products/page.tsx +++ b/frontend/app/[locale]/shop/admin/products/page.tsx @@ -1,13 +1,23 @@ import { Link } from '@/i18n/routing'; import { and, desc, eq } from 'drizzle-orm'; +import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; +import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; import { AdminProductStatusToggle } from '@/components/shop/admin/admin-product-status-toggle'; +import { AdminPagination } from '@/components/shop/admin/admin-pagination'; import { db } from '@/db'; import { products, productPrices } from '@/db/schema'; import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; import { logWarn } from '@/lib/logging'; +const PAGE_SIZE = 25; + +function parsePage(input: string | undefined): number { + const n = Number.parseInt(input ?? '1', 10); + return Number.isFinite(n) && n > 0 ? n : 1; +} + function formatDate(value: Date | null, locale: string) { if (!value) return '-'; return value.toLocaleDateString(locale); @@ -17,7 +27,6 @@ function safeFromDbMoney( value: unknown, ctx: { productId: string; currency: string } ): number | null { - // expected case for leftJoin: missing price row if (value == null) return null; try { @@ -35,15 +44,21 @@ function safeFromDbMoney( export default async function AdminProductsPage({ params, + searchParams, }: { params: Promise<{ locale: string }>; + searchParams: Promise<{ page?: string }>; }) { + await guardShopAdminPage(); const { locale } = await params; + const sp = await searchParams; + + const page = parsePage(sp.page); + const offset = (page - 1) * PAGE_SIZE; - // currency policy: derived from locale const displayCurrency = resolveCurrencyFromLocale(locale); - const rows = await db + const all = await db .select({ id: products.id, title: products.title, @@ -55,7 +70,7 @@ export default async function AdminProductsPage({ isActive: products.isActive, isFeatured: products.isFeatured, createdAt: products.createdAt, - price: productPrices.price, // numeric (major) from product_prices + price: productPrices.price, }) .from(products) .leftJoin( @@ -65,155 +80,127 @@ export default async function AdminProductsPage({ eq(productPrices.currency, displayCurrency) ) ) - .orderBy(desc(products.createdAt)); + // стабільне сортування (tie-breaker) + .orderBy(desc(products.createdAt), desc(products.id)) + .limit(PAGE_SIZE + 1) + .offset(offset); - return ( -
-
-

Admin · Products

- - New product - -
+ const hasNext = all.length > PAGE_SIZE; + const rows = all.slice(0, PAGE_SIZE); -
- - - - - - - - - - - - - - - - - - - {rows.map(row => { - const priceMinor = safeFromDbMoney(row.price, { - productId: row.id, - currency: displayCurrency, - }); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - ); - })} - -
- Title - - Slug - - Price - - Category - - Type - - Stock - - Badge - - Active - - Featured - - Created - - Actions -
-
- {row.title} -
-
-
- {row.slug} -
-
- {priceMinor === null - ? '-' - : formatMoney(priceMinor, displayCurrency, locale)} - -
- {row.category ?? '-'} -
-
-
- {row.type ?? '-'} -
-
- {row.stock} - - {row.badge === 'NONE' ? '-' : row.badge} - - - {row.isActive ? 'Yes' : 'No'} - - - - {row.isFeatured ? 'Yes' : 'No'} - - - {formatDate(row.createdAt, locale)} - -
- - View - - - Edit - - -
-
+ return ( + <> + +
+
+

Admin · Products

+ + New product + +
+ +
+ + + + + + + + + + + + + + + + + + + {rows.map(row => { + const priceMinor = safeFromDbMoney(row.price, { + productId: row.id, + currency: displayCurrency, + }); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ); + })} + +
TitleSlugPriceCategoryTypeStockBadgeActiveFeaturedCreatedActions
+
{row.title}
+
+
{row.slug}
+
+ {priceMinor === null ? '-' : formatMoney(priceMinor, displayCurrency, locale)} + +
{row.category ?? '-'}
+
+
{row.type ?? '-'}
+
{row.stock} + {row.badge === 'NONE' ? '-' : row.badge} + + + {row.isActive ? 'Yes' : 'No'} + + + + {row.isFeatured ? 'Yes' : 'No'} + + + {formatDate(row.createdAt, locale)} + +
+ + View + + + Edit + + +
+
+ + +
-
+ ); } diff --git a/frontend/app/[locale]/shop/cart/page.tsx b/frontend/app/[locale]/shop/cart/page.tsx index e6b7655c..a74a63dc 100644 --- a/frontend/app/[locale]/shop/cart/page.tsx +++ b/frontend/app/[locale]/shop/cart/page.tsx @@ -222,7 +222,7 @@ export default function CartPage() {
- {formatMoney(item.lineTotal, item.currency, locale)} + {formatMoney(item.lineTotalMinor, item.currency, locale)}
@@ -240,7 +240,7 @@ export default function CartPage() { Subtotal {formatMoney( - cart.summary.totalAmount, + cart.summary.totalAmountMinor, cart.summary.currency, locale )} @@ -259,7 +259,7 @@ export default function CartPage() { {formatMoney( - cart.summary.totalAmount, + cart.summary.totalAmountMinor, cart.summary.currency, locale )} diff --git a/frontend/app/[locale]/shop/layout.tsx b/frontend/app/[locale]/shop/layout.tsx deleted file mode 100644 index 0315ed4e..00000000 --- a/frontend/app/[locale]/shop/layout.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import type React from 'react'; -import './shop-theme.css'; - -export default function ShopLayout({ children }: { children: React.ReactNode }) { - return
{children}
; -} diff --git a/frontend/app/[locale]/shop/products/page.tsx b/frontend/app/[locale]/shop/products/page.tsx index f4c369fb..e2f4e22f 100644 --- a/frontend/app/[locale]/shop/products/page.tsx +++ b/frontend/app/[locale]/shop/products/page.tsx @@ -1,13 +1,13 @@ import { Suspense } from 'react'; import { Filter } from 'lucide-react'; -import { ProductCard } from '@/components/shop/product-card'; import { ProductFilters } from '@/components/shop/product-filters'; import { ProductSort } from '@/components/shop/product-sort'; -import { CatalogLoadMore } from '@/components/shop/catalog-load-more'; +import { CatalogProductsClient } from '@/components/shop/catalog-products-client'; import { getCatalogProducts } from '@/lib/shop/data'; import { catalogQuerySchema } from '@/lib/validation/shop'; import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog'; +import { redirect } from 'next/navigation'; type RawSearchParams = { category?: string; @@ -28,13 +28,35 @@ export default async function ProductsPage({ }: ProductsPageProps & { params: Promise<{ locale: string }> }) { const { locale } = await params; const resolvedSearchParams = (await searchParams) ?? {}; + // canonicalize: infinite-load page should not be shareable as ?page=N + if (resolvedSearchParams.page) { + const qsParams = new URLSearchParams(); + + for (const [k, v] of Object.entries(resolvedSearchParams)) { + if (!v) continue; + if (k === 'page') continue; + qsParams.set(k, v); + } + + const qs = qsParams.toString(); + const basePath = `/${locale}/shop/products`; + + redirect(qs ? `${basePath}?${qs}` : basePath); + } const parsedParams = catalogQuerySchema.safeParse(resolvedSearchParams); - const filters = parsedParams.success + const parsed = parsedParams.success ? parsedParams.data : { page: 1, limit: CATALOG_PAGE_SIZE }; + // Для “Load more” UX: починаємо завжди з 1-ї сторінки (URL ?page=... ігноруємо). + const filters = { + ...parsed, + page: 1, + limit: parsed.limit ?? CATALOG_PAGE_SIZE, + }; + const catalog = await getCatalogProducts(filters, locale); return ( @@ -72,20 +94,7 @@ export default async function ProductsPage({

) : ( - <> -
- {catalog.products.map(product => ( - - ))} -
- -
- -
- + )} diff --git a/frontend/app/[locale]/shop/shop-theme.css b/frontend/app/[locale]/shop/shop-theme.css deleted file mode 100644 index 6b3c3f76..00000000 --- a/frontend/app/[locale]/shop/shop-theme.css +++ /dev/null @@ -1,89 +0,0 @@ -.shop-scope { - --radius: 0.5rem; - - --background: #ffffff; - --foreground: #111111; - - --card: #ffffff; - --card-foreground: #111111; - - --popover: #ffffff; - --popover-foreground: #111111; - - --primary: #111111; - --primary-foreground: #ffffff; - - --secondary: #f5f5f5; - --secondary-foreground: #111111; - - --muted: #f5f5f5; - --muted-foreground: #555555; - - --accent: #ff2d55; - --accent-foreground: #ffffff; - - --destructive: oklch(0.577 0.245 27.325); - - --border: #e5e5e5; - --input: #e5e5e5; - --ring: #ff2d55; - - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); -} - -.dark .shop-scope { - --background: #0a0a0a; - --foreground: #ffffff; - - --card: #0a0a0a; - --card-foreground: #ffffff; - - --popover: #0a0a0a; - --popover-foreground: #ffffff; - - --primary: #ffffff; - --primary-foreground: #0a0a0a; - - --secondary: #1c1c1e; - --secondary-foreground: #ffffff; - - --muted: #1c1c1e; - --muted-foreground: #cccccc; - - --accent: #ff2d55; - --accent-foreground: #ffffff; - - --destructive: oklch(0.396 0.141 25.723); - - --border: #333333; - --input: #333333; - --ring: #ff2d55; - - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.269 0 0); - --sidebar-ring: oklch(0.439 0 0); -} diff --git a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts index 062846c9..5f402676 100644 --- a/frontend/app/api/shop/admin/orders/[id]/refund/route.ts +++ b/frontend/app/api/shop/admin/orders/[id]/refund/route.ts @@ -28,7 +28,9 @@ export async function POST( ); } - const order = await refundOrder(parsed.data.id); + // app/api/shop/admin/orders/[id]/refund/route.ts + const order = await refundOrder(parsed.data.id, { requestedBy: 'admin' }); + const orderSummary = orderSummarySchema.parse(order); return NextResponse.json({ diff --git a/frontend/app/api/shop/catalog/route.ts b/frontend/app/api/shop/catalog/route.ts new file mode 100644 index 00000000..66fc37a9 --- /dev/null +++ b/frontend/app/api/shop/catalog/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; + +import { getCatalogProducts } from '@/lib/shop/data'; +import { catalogQuerySchema } from '@/lib/validation/shop'; +import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog'; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +type RawSearchParams = { + category?: string; + type?: string; + color?: string; + size?: string; + sort?: string; + page?: string; + limit?: string; + locale?: string; +}; + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const raw = Object.fromEntries(url.searchParams.entries()) as RawSearchParams; + + const { locale, ...rest } = raw; + const effectiveLocale = locale ?? 'en'; + + const parsed = catalogQuerySchema.safeParse(rest); + + const filters = parsed.success + ? parsed.data + : { page: 1, limit: CATALOG_PAGE_SIZE }; + + const catalog = await getCatalogProducts(filters, effectiveLocale); + + return NextResponse.json(catalog, { + headers: { 'Cache-Control': 'no-store' }, + }); +} diff --git a/frontend/app/api/shop/checkout/route.ts b/frontend/app/api/shop/checkout/route.ts index 0cd6181c..b5539c43 100644 --- a/frontend/app/api/shop/checkout/route.ts +++ b/frontend/app/api/shop/checkout/route.ts @@ -157,11 +157,7 @@ export async function POST(request: NextRequest) { logWarn('Failed to parse cart payload', { reason: error instanceof Error ? error.message : String(error), }); - return errorResponse( - 'INVALID_PAYLOAD', - 'Unable to process cart data.', - 400 - ); + return errorResponse('INVALID_PAYLOAD', 'Unable to process cart data.', 400); } const idempotencyKey = getIdempotencyKey(request); @@ -241,22 +237,13 @@ export async function POST(request: NextRequest) { const paymentsEnabled = isPaymentsEnabled(); if (!paymentsEnabled) { - // If the order already failed (inventory or other), return a stable conflict instead of 500. - if ( - order.paymentProvider === 'none' && - order.paymentStatus === 'failed' - ) { - return errorResponse( - 'CHECKOUT_FAILED', - 'Order could not be completed.', - 409, - { orderId: order.id } - ); + if (order.paymentProvider === 'none' && order.paymentStatus === 'failed') { + return errorResponse('CHECKOUT_FAILED', 'Order could not be completed.', 409, { + orderId: order.id, + }); } - if ( - order.paymentProvider === 'stripe' && - order.paymentStatus !== 'paid' - ) { + + if (order.paymentProvider === 'stripe' && order.paymentStatus !== 'paid') { return errorResponse( 'PAYMENTS_DISABLED', 'Payments are disabled. This order requires payment and cannot be processed.', @@ -266,16 +253,9 @@ export async function POST(request: NextRequest) { } if (order.paymentProvider === 'none') { - if ( - !['paid', 'failed'].includes(order.paymentStatus) || - order.paymentIntentId - ) { + if (!['paid', 'failed'].includes(order.paymentStatus) || order.paymentIntentId) { logError( - `Payments disabled but order is not paid/none. orderId=${ - order.id - } provider=${order.paymentProvider} status=${ - order.paymentStatus - } intent=${order.paymentIntentId ?? 'null'}`, + `Payments disabled but order is not paid/none. orderId=${order.id} provider=${order.paymentProvider} status=${order.paymentStatus} intent=${order.paymentIntentId ?? 'null'}`, new Error('ORDER_STATE_INVALID') ); return errorResponse( @@ -287,16 +267,16 @@ export async function POST(request: NextRequest) { } } - const stripePaymentFlow = - paymentsEnabled && order.paymentProvider === 'stripe'; + const stripePaymentFlow = paymentsEnabled && order.paymentProvider === 'stripe'; + // ========================= + // Existing order path + // ========================= if (!result.isNew) { + // Existing order already has PI: retrieve client_secret if (stripePaymentFlow && order.paymentIntentId) { try { - const paymentIntent = await retrievePaymentIntent( - order.paymentIntentId - ); - + const paymentIntent = await retrievePaymentIntent(order.paymentIntentId); return buildCheckoutResponse({ order: { id: order.id, @@ -312,23 +292,27 @@ export async function POST(request: NextRequest) { }); } catch (error) { logError('Checkout payment intent retrieval failed', error); - return errorResponse( - 'STRIPE_ERROR', - 'Unable to initiate payment.', - 400 - ); + return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); } } + // Existing order without PI: create PI then attach (post-create => never 400) if (stripePaymentFlow && !order.paymentIntentId) { + let paymentIntent: { paymentIntentId: string; clientSecret: string }; + try { - const paymentIntent = await createPaymentIntent({ + paymentIntent = await createPaymentIntent({ amount: totalCents, currency: order.currency, orderId: order.id, idempotencyKey, }); + } catch (error) { + logError('Checkout payment intent creation failed', error); + return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); + } + try { const updatedOrder = await setOrderPaymentIntent({ orderId: order.id, paymentIntentId: paymentIntent.paymentIntentId, @@ -348,15 +332,30 @@ export async function POST(request: NextRequest) { status: 200, }); } catch (error) { - logError('Checkout payment intent creation failed', error); - return errorResponse( - 'STRIPE_ERROR', - 'Unable to initiate payment.', - 400 - ); + logError('Checkout payment intent attach failed', error); + + // Post-create => conflict, not 400 + if (error instanceof InvalidPayloadError) { + return errorResponse( + 'CHECKOUT_CONFLICT', + 'Order state conflict while attaching payment intent. Retry with the same Idempotency-Key.', + 409, + { orderId: order.id } + ); + } + + if (error instanceof OrderStateInvalidError) { + return errorResponse(error.code, error.message, 500, { + orderId: error.orderId, + ...(error.details ? { details: error.details } : {}), + }); + } + + return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); } } + // Not Stripe flow => return existing order as-is return buildCheckoutResponse({ order: { id: order.id, @@ -372,6 +371,9 @@ export async function POST(request: NextRequest) { }); } + // ========================= + // New order path + // ========================= if (!stripePaymentFlow) { return buildCheckoutResponse({ order: { @@ -388,14 +390,30 @@ export async function POST(request: NextRequest) { }); } + // Stripe new order: Phase 1 PSP call (if fails => restock best-effort, return 502) + let paymentIntent: { paymentIntentId: string; clientSecret: string }; + try { - const paymentIntent = await createPaymentIntent({ + paymentIntent = await createPaymentIntent({ amount: totalCents, currency: order.currency, orderId: order.id, idempotencyKey, }); + } catch (error) { + logError('Checkout payment intent creation failed', error); + try { + await restockOrder(order.id, { reason: 'failed' }); + } catch (restockError) { + logError('Restoring stock after payment intent failure failed', restockError); + } + + return errorResponse('STRIPE_ERROR', 'Unable to initiate payment.', 502); + } + + // Stripe new order: Phase 2 attach PI (post-create => never 400) + try { const updatedOrder = await setOrderPaymentIntent({ orderId: order.id, paymentIntentId: paymentIntent.paymentIntentId, @@ -415,36 +433,34 @@ export async function POST(request: NextRequest) { status: 201, }); } catch (error) { - logError('Checkout payment intent creation failed', error); + logError('Checkout payment intent attach failed', error); - try { - await restockOrder(order.id, { reason: 'failed' }); - } catch (restockError) { - logError( - 'Restoring stock after payment intent failure failed', - restockError + if (error instanceof InvalidPayloadError) { + // Conflict/race/state issue. Do NOT return 400. + // Leave inventory reserved; retry with same idempotency key or janitor will sweep. + return errorResponse( + 'CHECKOUT_CONFLICT', + 'Order state conflict while attaching payment intent. Retry with the same Idempotency-Key.', + 409, + { orderId: order.id } ); } - if (error instanceof Error && error.message.startsWith('STRIPE_')) { - return errorResponse( - 'STRIPE_ERROR', - 'Unable to initiate payment.', - 400 - ); + // For non-conflict attach failures: best-effort release to avoid stock lock + try { + await restockOrder(order.id, { reason: 'failed' }); + } catch (restockError) { + logError('Restoring stock after payment intent attach failure failed', restockError); } if (error instanceof OrderStateInvalidError) { return errorResponse(error.code, error.message, 500, { orderId: error.orderId, + ...(error.details ? { details: error.details } : {}), }); } - return errorResponse( - 'INTERNAL_ERROR', - 'Unable to process checkout.', - 500 - ); + return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); } } catch (error) { if (isExpectedBusinessError(error)) { @@ -457,11 +473,7 @@ export async function POST(request: NextRequest) { } if (error instanceof InvalidPayloadError) { - return errorResponse( - error.code, - error.message || 'Invalid checkout payload', - 400 - ); + return errorResponse(error.code, error.message || 'Invalid checkout payload', 400); } if (error instanceof InvalidVariantError) { @@ -512,4 +524,4 @@ export async function POST(request: NextRequest) { return errorResponse('INTERNAL_ERROR', 'Unable to process checkout.', 500); } -} +} \ No newline at end of file diff --git a/frontend/app/api/shop/webhooks/stripe/route.ts b/frontend/app/api/shop/webhooks/stripe/route.ts index 85142670..e2d3f009 100644 --- a/frontend/app/api/shop/webhooks/stripe/route.ts +++ b/frontend/app/api/shop/webhooks/stripe/route.ts @@ -9,6 +9,82 @@ import { restockOrder } from '@/lib/services/orders'; import { guardedPaymentStatusUpdate } from '@/lib/services/orders/payment-state'; import { logError, logInfo, logWarn } from '@/lib/logging'; +type RefundMetaRecord = { + refundId: string; + idempotencyKey: string; + amountMinor: number; + currency: string; + createdAt: string; + createdBy: string; + status?: string | null; +}; + +function normalizeRefundsFromMeta( + meta: unknown, + fallback: { currency: string; createdAt: string } +): RefundMetaRecord[] { + const m = (meta ?? {}) as any; + + if (Array.isArray(m.refunds)) return m.refunds as RefundMetaRecord[]; + + const legacy = m.refund; + if (legacy?.id) { + return [ + { + refundId: String(legacy.id), + idempotencyKey: 'legacy:webhook', + amountMinor: Number(legacy.amount ?? 0), + currency: fallback.currency, + createdAt: fallback.createdAt, + createdBy: 'webhook', + status: legacy.status ?? null, + }, + ]; + } + + return []; +} + +function upsertRefundIntoMeta(params: { + prevMeta: unknown; + refund: { id: string; amount?: number | null; status?: string | null } | null; + eventId: string; + currency: string; + createdAtIso: string; +}): any { + const { prevMeta, refund, eventId, currency, createdAtIso } = params; + + const base = ((prevMeta ?? {}) as any) ?? {}; + + // якщо refund в payload нема — просто повертаємо base (але НЕ затираємо refunds) + if (!refund?.id) return base; + + const refunds = normalizeRefundsFromMeta(base, { + currency, + createdAt: createdAtIso, + }); + + const rec: RefundMetaRecord = { + refundId: refund.id, + idempotencyKey: `webhook:${eventId}`.slice(0, 128), + amountMinor: Number(refund.amount ?? 0), + currency, + createdAt: createdAtIso, + createdBy: 'webhook', + status: refund.status ?? null, + }; + + const exists = refunds.some( + r => r.refundId === rec.refundId || r.idempotencyKey === rec.idempotencyKey + ); + + return { + ...base, + refunds: exists ? refunds : [...refunds, rec], + refundInitiatedAt: base.refundInitiatedAt ?? createdAtIso, + }; +} + function warnRefundFullnessUndetermined(payload: { eventId: string; eventType: string; @@ -131,6 +207,52 @@ function buildPspMetadata(params: { }; } +function stripUndefined(obj: Record) { + const out: Record = {}; + for (const [k, v] of Object.entries(obj)) { + if (v !== undefined) out[k] = v; + } + return out; +} + +function mergePspMetadata(params: { + prevMeta: unknown; + delta: Record; + eventId: string; + currency: string; + createdAtIso: string; +}) { + const cleanedDelta = stripUndefined(params.delta); + + const refundForUpsert = (cleanedDelta as any)?.refund?.id + ? { + id: String((cleanedDelta as any).refund.id), + amount: + typeof (cleanedDelta as any).refund.amount === 'number' + ? (cleanedDelta as any).refund.amount + : null, + status: + typeof (cleanedDelta as any).refund.status === 'string' + ? (cleanedDelta as any).refund.status + : null, + } + : null; + + const metaWithRefunds = upsertRefundIntoMeta({ + prevMeta: params.prevMeta, + refund: refundForUpsert, + eventId: params.eventId, + currency: params.currency, + createdAtIso: params.createdAtIso, + }); + + // IMPORTANT: merge, not overwrite (preserves refunds[]) + return { + ...metaWithRefunds, + ...cleanedDelta, + }; +} + function shouldRestockFromWebhook(order: { stockRestored: boolean | null; inventoryStatus: string | null; @@ -342,6 +464,7 @@ export async function POST(request: NextRequest) { status: orders.status, stockRestored: orders.stockRestored, inventoryStatus: orders.inventoryStatus, + pspMetadata: orders.pspMetadata, }) .from(orders) .where(eq(orders.id, resolvedOrderId)) @@ -383,37 +506,42 @@ export async function POST(request: NextRequest) { : 'currency_mismatch'; const chargeForIntent = getLatestCharge(paymentIntent as any); + const createdAtIso = new Date().toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent, + extra: { + mismatch: { + reason: mismatchReason, + eventId: event.id, + expected: { + amountMinor: orderAmountMinor, + currency: order.currency, + }, + actual: { amountMinor: stripeAmount, currency: stripeCurrency }, + // keep old fields for backward-compat/debug grepping + stripeAmount, + orderAmountMinor, + stripeCurrency, + orderCurrency: order.currency, + }, + }, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await db .update(orders) .set({ updatedAt: new Date(), pspStatusReason: mismatchReason, - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent, - extra: { - mismatch: { - reason: mismatchReason, - eventId: event.id, - expected: { - amountMinor: orderAmountMinor, - currency: order.currency, - }, - actual: { - amountMinor: stripeAmount, - currency: stripeCurrency, - }, - - // keep old fields for backward-compat/debug grepping - stripeAmount, - orderAmountMinor, - stripeCurrency, - orderCurrency: order.currency, - }, - }, - }), + pspMetadata: nextMeta, }) .where(eq(orders.id, order.id)); @@ -435,6 +563,19 @@ export async function POST(request: NextRequest) { const latestChargeId = getLatestChargeId(paymentIntent); const now = new Date(); + const createdAtIso = now.toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent ?? undefined, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await guardedPaymentStatusUpdate({ orderId: order.id, @@ -452,11 +593,7 @@ export async function POST(request: NextRequest) { chargeForIntent ), pspStatusReason: paymentIntent?.status ?? 'succeeded', - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent ?? undefined, - }), + pspMetadata: nextMeta, }, extraWhere: and( eq(orders.stockRestored, false), @@ -504,7 +641,20 @@ export async function POST(request: NextRequest) { paymentIntent?.cancellation_reason ?? paymentIntent?.status ?? 'payment_failed'; - + const now = new Date(); + const createdAtIso = now.toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await guardedPaymentStatusUpdate({ orderId: order.id, paymentProvider: 'stripe', @@ -513,18 +663,14 @@ export async function POST(request: NextRequest) { eventId: event.id, note: eventType, set: { - updatedAt: new Date(), + updatedAt: now, pspChargeId: chargeForIntent?.id ?? null, pspPaymentMethod: resolvePaymentMethod( paymentIntent, chargeForIntent ), pspStatusReason: failureReason, - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent, - }), + pspMetadata: nextMeta, }, }); @@ -565,7 +711,20 @@ export async function POST(request: NextRequest) { paymentIntent?.cancellation_reason ?? paymentIntent?.status ?? 'canceled'; - + const now = new Date(); + const createdAtIso = now.toISOString(); + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: chargeForIntent, + }); + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); await guardedPaymentStatusUpdate({ orderId: order.id, paymentProvider: 'stripe', @@ -574,18 +733,14 @@ export async function POST(request: NextRequest) { eventId: event.id, note: eventType, set: { - updatedAt: new Date(), + updatedAt: now, pspChargeId: chargeForIntent?.id ?? null, pspPaymentMethod: resolvePaymentMethod( paymentIntent, chargeForIntent ), pspStatusReason: cancellationReason, - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: chargeForIntent, - }), + pspMetadata: nextMeta, }, }); @@ -620,8 +775,6 @@ export async function POST(request: NextRequest) { : null; // MVP: only FULL refund. - // - charge.refunded: amount_refunded === amount (or fallback sum(refunds)) - // - charge.refund.updated: compare cumulative refunded for the charge vs charge.amount let isFullRefund = false; if (eventType === 'charge.refunded') { @@ -637,8 +790,6 @@ export async function POST(request: NextRequest) { ? (effectiveCharge as any).amount_refunded : null; - // Fail-safe fallback: if amount_refunded missing, try refunds list; - // if list absent/empty -> UNDETERMINED (500 + retry), NOT "0 refunded". if (cumulativeRefunded == null) { const list = Array.isArray((effectiveCharge as any)?.refunds?.data) ? ((effectiveCharge as any).refunds.data as any[]) @@ -677,6 +828,7 @@ export async function POST(request: NextRequest) { sawNumericAmount = true; return sum + a; }, 0); + if (!sawNumericAmount && currentAmt == null) { warnRefundFullnessUndetermined({ eventId: event.id, @@ -701,13 +853,14 @@ 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); } + if (amt == null || cumulativeRefunded == null) { const list = Array.isArray((effectiveCharge as any)?.refunds?.data) ? ((effectiveCharge as any).refunds.data as any[]) : null; + warnRefundFullnessUndetermined({ eventId: event.id, eventType, @@ -733,13 +886,11 @@ export async function POST(request: NextRequest) { isFullRefund = cumulativeRefunded === amt; } else if (eventType === 'charge.refund.updated' && refund) { - // Ensure we have the Charge to compute cumulative refunded correctly. let effectiveCharge: Stripe.Charge | undefined; if (typeof refund.charge === 'object' && refund.charge) { effectiveCharge = refund.charge as Stripe.Charge; } else if (typeof refund.charge === 'string' && refund.charge.trim()) { - // Fetch charge to get cumulative refunded / refunds list effectiveCharge = await retrieveCharge(refund.charge.trim()); } @@ -819,7 +970,6 @@ export async function POST(request: NextRequest) { } const hasCurrent = list.some(r => r?.id && r.id === refund.id); - cumulativeRefunded = sumFromList + (hasCurrent ? 0 : currentAmt ?? 0); } @@ -827,6 +977,7 @@ export async function POST(request: NextRequest) { const list = Array.isArray((effectiveCharge as any)?.refunds?.data) ? ((effectiveCharge as any).refunds.data as any[]) : null; + warnRefundFullnessUndetermined({ eventId: event.id, eventType, @@ -854,38 +1005,49 @@ export async function POST(request: NextRequest) { isFullRefund = cumulativeRefunded === amt; - // Prefer charge id from effectiveCharge for PSP fields if (effectiveCharge?.id) { charge = effectiveCharge; } } + const now = new Date(); + const createdAtIso = now.toISOString(); + if (!isFullRefund) { + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: charge ?? undefined, + refund, + extra: { + refundGate: { + decision: 'ignored', + expectedAmountMinor: order.totalAmountMinor, + chargeAmount: (charge as any)?.amount ?? null, + chargeAmountRefunded: (charge as any)?.amount_refunded ?? null, + refundAmount: (refund as any)?.amount ?? null, + eventId: event.id, + }, + }, + }); + + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + await db .update(orders) .set({ - updatedAt: new Date(), + 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', - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: charge ?? undefined, - refund, - extra: { - refundGate: { - decision: 'ignored', - expectedAmountMinor: order.totalAmountMinor, - chargeAmount: (charge as any)?.amount ?? null, - chargeAmountRefunded: - (charge as any)?.amount_refunded ?? null, - refundAmount: (refund as any)?.amount ?? null, - eventId: event.id, - }, - }, - }), }) .where(eq(orders.id, order.id)); @@ -898,6 +1060,21 @@ export async function POST(request: NextRequest) { return ack(); } + const deltaMeta = buildPspMetadata({ + eventType, + paymentIntent, + charge: charge ?? undefined, + refund, + }); + + const nextMeta = mergePspMetadata({ + prevMeta: order.pspMetadata, + delta: deltaMeta as any, + eventId: event.id, + currency: order.currency, + createdAtIso, + }); + const refundRes = await guardedPaymentStatusUpdate({ orderId: order.id, paymentProvider: 'stripe', @@ -906,17 +1083,12 @@ export async function POST(request: NextRequest) { eventId: event.id, note: eventType, set: { - updatedAt: new Date(), + updatedAt: now, status: 'CANCELED', // terminal in current enum pspChargeId: charge?.id ?? refundChargeId ?? null, pspPaymentMethod: resolvePaymentMethod(paymentIntent, charge), pspStatusReason: refund?.reason ?? refund?.status ?? 'refunded', - pspMetadata: buildPspMetadata({ - eventType, - paymentIntent, - charge: charge ?? undefined, - refund, - }), + pspMetadata: nextMeta, }, }); diff --git a/frontend/app/globals.css b/frontend/app/globals.css index b25f8169..1577945b 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -160,3 +160,40 @@ transform: scale(1.08); } } + + +/* Shop theme: scoped overrides (must not affect platform pages) */ +.shop-scope { + /* keep shop rounding slightly tighter than platform */ + --radius: calc(var(--radius) - 2px); + + /* light: shop accent = black */ + --accent: var(--foreground); + --accent-foreground: var(--background); + --ring: var(--foreground); + + /* IMPORTANT: override Tailwind v4 theme vars directly */ + --color-accent: var(--foreground); + --color-accent-foreground: var(--background); + --color-ring: var(--foreground); + + --card: var(--background); +} + +.dark .shop-scope { + /* dark: shop accent = magenta */ + --accent: var(--accent-primary); + --accent-foreground: var(--foreground); + --ring: var(--accent-primary); + + /* IMPORTANT: override Tailwind v4 theme vars directly */ + --color-accent: var(--accent-primary); + --color-accent-foreground: var(--foreground); + --color-ring: var(--accent-primary); + + --card: var(--background); + + /* keep borders closer to previous shop look, derived (no hex) */ + --border: color-mix(in oklab, var(--foreground) 18%, var(--background)); + --input: color-mix(in oklab, var(--foreground) 18%, var(--background)); +} diff --git a/frontend/components/header/AppMobileMenu.tsx b/frontend/components/header/AppMobileMenu.tsx index a2ab8aac..239cb3c9 100644 --- a/frontend/components/header/AppMobileMenu.tsx +++ b/frontend/components/header/AppMobileMenu.tsx @@ -16,7 +16,11 @@ type Props = { showAdminLink?: boolean; }; -export function AppMobileMenu({ variant, userExists, showAdminLink = false }: Props) { +export function AppMobileMenu({ + variant, + userExists, + showAdminLink = false, +}: Props) { const [open, setOpen] = useState(false); const close = () => setOpen(false); @@ -65,6 +69,16 @@ export function AppMobileMenu({ variant, userExists, showAdminLink = false }: Pr className="fixed left-0 right-0 top-16 z-50 border-t border-border bg-background px-4 py-4 md:hidden" >
+ {variant === 'shop' ? ( + + Home + + ) : null} + {links.map(link => (
-