From 4823bfbe6631bc959e74f2571b92cca1debd8e62 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Thu, 15 Jan 2026 17:39:03 -0800 Subject: [PATCH 1/7] (SP: 1) [Shop UI] Improve semantics + styles across shop; add admin header button (admin role only) --- .hintrc | 9 +- frontend/app/[locale]/layout.tsx | 8 +- .../shop/admin/orders/[id]/RefundButton.tsx | 21 +- .../[locale]/shop/admin/orders/[id]/page.tsx | 270 ++++++++------- .../app/[locale]/shop/admin/orders/page.tsx | 210 ++++++++---- frontend/app/[locale]/shop/admin/page.tsx | 80 +++-- .../shop/admin/products/[id]/edit/page.tsx | 52 +-- .../products/_components/product-form.tsx | 195 ++++++++--- .../[locale]/shop/admin/products/new/page.tsx | 10 +- .../app/[locale]/shop/admin/products/page.tsx | 290 +++++++++++------ frontend/app/[locale]/shop/cart/page.tsx | 278 +++++++++------- .../app/[locale]/shop/checkout/error/page.tsx | 238 ++++++++++---- .../checkout/payment/StripePaymentClient.tsx | 75 +++-- .../shop/checkout/payment/[orderId]/page.tsx | 237 ++++++++------ .../success/OrderStatusAutoRefresh.tsx | 4 +- .../[locale]/shop/checkout/success/page.tsx | 200 +++++++----- .../app/[locale]/shop/orders/[id]/page.tsx | 275 ++++++++++++++++ frontend/app/[locale]/shop/orders/error.tsx | 25 ++ frontend/app/[locale]/shop/orders/page.tsx | 307 ++++++++++++++++++ frontend/app/[locale]/shop/page.tsx | 67 +++- .../[locale]/shop/products/[slug]/page.tsx | 87 ++--- frontend/app/[locale]/shop/products/page.tsx | 53 +-- frontend/components/header/AppChrome.tsx | 2 +- frontend/components/header/AppMobileMenu.tsx | 11 + frontend/components/header/UnifiedHeader.tsx | 19 +- .../components/shop/add-to-cart-button.tsx | 170 ++++++---- .../shop/admin/admin-pagination.tsx | 69 ++-- .../admin/admin-product-status-toggle.tsx | 39 ++- .../shop/admin/shop-admin-topbar.tsx | 71 ++-- frontend/components/shop/cart-provider.tsx | 30 +- .../components/shop/catalog-load-more.tsx | 20 +- .../shop/catalog-products-client.tsx | 35 +- frontend/components/shop/category-tile.tsx | 43 ++- .../components/shop/header/cart-button.tsx | 38 ++- frontend/components/shop/header/nav-links.tsx | 124 ++++--- .../components/shop/header/theme-toggle.tsx | 23 -- frontend/components/shop/product-card.tsx | 6 +- frontend/components/shop/product-filters.tsx | 205 +++++++----- frontend/components/shop/product-sort.tsx | 79 +++-- frontend/components/shop/products-toolbar.tsx | 136 ++++++++ frontend/components/shop/shop-footer.tsx | 125 ------- frontend/components/shop/shop-hero.tsx | 21 +- frontend/components/shop/theme-provider.tsx | 27 -- frontend/db/queries/shop/products.ts | 9 +- frontend/lib/config/catalog.ts | 6 +- frontend/lib/shop/data.ts | 43 ++- frontend/public/apparel.jpg | Bin 0 -> 234651 bytes frontend/public/collectibles.jpg | Bin 0 -> 105407 bytes frontend/public/lifestyle.jpg | Bin 0 -> 192992 bytes 49 files changed, 2935 insertions(+), 1407 deletions(-) create mode 100644 frontend/app/[locale]/shop/orders/[id]/page.tsx create mode 100644 frontend/app/[locale]/shop/orders/error.tsx create mode 100644 frontend/app/[locale]/shop/orders/page.tsx delete mode 100644 frontend/components/shop/header/theme-toggle.tsx create mode 100644 frontend/components/shop/products-toolbar.tsx delete mode 100644 frontend/components/shop/shop-footer.tsx delete mode 100644 frontend/components/shop/theme-provider.tsx create mode 100644 frontend/public/apparel.jpg create mode 100644 frontend/public/collectibles.jpg create mode 100644 frontend/public/lifestyle.jpg diff --git a/.hintrc b/.hintrc index e099e671..42688306 100644 --- a/.hintrc +++ b/.hintrc @@ -8,6 +8,13 @@ { "aria-valid-attr-value": "off" } - ] + ], + "axe/structure": [ + "default", + { + "list": "off" + } + ], + "no-inline-styles": "off" } } \ No newline at end of file diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index 96f569b3..6ee17054 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -31,7 +31,13 @@ export default async function LocaleLayout({ const user = await getCurrentUser(); const userExists = Boolean(user); - const showAdminNavLink = process.env.NEXT_PUBLIC_ENABLE_ADMIN === 'true'; + const enableAdmin = + (process.env.ENABLE_ADMIN_API ?? process.env.NEXT_PUBLIC_ENABLE_ADMIN ?? '') + .toLowerCase() === 'true'; + + // підстав свій реальний шлях до ролі + const isAdmin = user?.role === 'admin'; // або 'ADMIN', або isShopAdmin === true + const showAdminNavLink = Boolean(user) && isAdmin && enableAdmin; return ( diff --git a/frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx b/frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx index 39a07470..946d2d1a 100644 --- a/frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx +++ b/frontend/app/[locale]/shop/admin/orders/[id]/RefundButton.tsx @@ -1,7 +1,7 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { useState, useTransition } from 'react'; +import { useId, useState, useTransition } from 'react'; type Props = { orderId: string; @@ -12,6 +12,7 @@ export function RefundButton({ orderId, disabled }: Props) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); + const errorId = useId(); async function onRefund() { setError(null); @@ -47,12 +48,17 @@ export function RefundButton({ orderId, disabled }: Props) { }); } + const isDisabled = disabled || isPending; + return (
); } diff --git a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx index ccb5f385..51537493 100644 --- a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx @@ -1,7 +1,9 @@ -import { Link } from '@/i18n/routing'; - +// frontend/app/[locale]/shop/admin/orders/[id]/page.tsx import { notFound } from 'next/navigation'; + +import { Link } from '@/i18n/routing'; import { RefundButton } from './RefundButton'; + import { getAdminOrderDetail } from '@/db/queries/shop/admin-orders'; import { formatMoney, @@ -28,7 +30,7 @@ function orderCurrency( return c === 'UAH' ? 'UAH' : 'USD'; } -function formatDateTime(value: Date | null | undefined) { +function formatDateTime(value: Date | null | undefined): string { if (!value) return '-'; return value.toLocaleString(); } @@ -39,7 +41,9 @@ 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(); @@ -48,19 +52,31 @@ export default async function AdminOrderDetailPage({ order.paymentStatus === 'paid' && !!order.paymentIntentId; + const currency = orderCurrency(order, locale); + + const totalMinor = pickMinor(order?.totalAmountMinor, order?.totalAmount); + const totalFormatted = + totalMinor === null ? '-' : formatMoney(totalMinor, currency, locale); + return ( <> -
-
-
-

Order

-

+ +

+
+
+

+ Order +

+

{order.id}

-
+
-
+
+ +
+
+

+ Summary +

-
-
-
Summary
Payment status
@@ -83,18 +111,7 @@ export default async function AdminOrderDetailPage({
Total
-
- {(() => { - const c = orderCurrency(order, locale); - const totalMinor = pickMinor( - order?.totalAmountMinor, - order?.totalAmount - ); - return totalMinor === null - ? '-' - : formatMoney(totalMinor, c, locale); - })()} -
+
{totalFormatted}
@@ -104,24 +121,31 @@ export default async function AdminOrderDetailPage({
Payment intent
-
+
{order.paymentIntentId ?? '-'}
Idempotency key
-
+
{order.idempotencyKey}
-
+
-
-
+
+

Stock / timestamps -

+ +
Created
@@ -129,18 +153,21 @@ export default async function AdminOrderDetailPage({ {formatDateTime(order.createdAt)}
+
Updated
{formatDateTime(order.updatedAt)}
+
Stock restored
{order.stockRestored ? 'Yes' : 'No'}
+
Restocked at
@@ -148,88 +175,111 @@ export default async function AdminOrderDetailPage({
-
-
- -
- - - - - - - - - - - - {order.items.map(item => ( - - - - - - - - - - ))} + + - {order.items.length === 0 ? ( +
+

+ Order items +

+ +
+
- Product - - Qty - - Unit - - Line total -
-
- {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); - })()} -
+ + + - + + + + - ) : null} - -
Line items for this order
- No items found for this order. - + Product + + Qty + + Unit + + Line total +
-
-
+ + + + {order.items.map(item => { + const unitMinor = pickMinor( + item?.unitPriceMinor, + item?.unitPrice + ); + const lineMinor = pickMinor( + item?.lineTotalMinor, + item?.lineTotal + ); + + const unitFormatted = + unitMinor === null + ? '-' + : formatMoney(unitMinor, currency, locale); + + const lineFormatted = + lineMinor === null + ? '-' + : formatMoney(lineMinor, currency, locale); + + return ( + + +
+ {item.productTitle ?? '-'} +
+
+ + {item.productSlug ?? '-'} + + {item.productSku ? ( + · {item.productSku} + ) : null} +
+ + + + {item.quantity} + + + + {unitFormatted} + + + + {lineFormatted} + + + ); + })} + + {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 2e0cea76..340341e7 100644 --- a/frontend/app/[locale]/shop/admin/orders/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/page.tsx @@ -1,3 +1,4 @@ +// frontend/app/[locale]/shop/admin/orders/page.tsx import { Link } from '@/i18n/routing'; import { getAdminOrdersPage } from '@/db/queries/shop/admin-orders'; @@ -27,10 +28,11 @@ function pickMinor(minor: unknown, legacyMajor: unknown): number | null { } function orderCurrency(order: any, locale: string): CurrencyCode { - return (order?.currency ?? resolveCurrencyFromLocale(locale)) as CurrencyCode; + const c = order?.currency ?? resolveCurrencyFromLocale(locale); + return c === 'UAH' ? 'UAH' : 'USD'; } -function formatDate(value: Date | null | undefined) { +function formatDate(value: Date | null | undefined): string { if (!value) return '-'; return value.toLocaleDateString(); } @@ -43,6 +45,7 @@ export default async function AdminOrdersPage({ searchParams: Promise<{ page?: string }>; }) { await guardShopAdminPage(); + const { locale } = await params; const sp = await searchParams; @@ -61,9 +64,18 @@ export default async function AdminOrdersPage({ return ( <> -
-
-

Admin · Orders

+ +
+
+

+ Admin · Orders +

- -
- - - - - - - - - - - - - - - {items.map(order => ( - - - - - - - - - - - - - - - ))} + + +
+
+
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 - -
+ - {items.length === 0 ? ( + - + + + + + + + - ) : null} - -
Orders list
- No orders yet. - + Created + + Status + + Total + + Items + + Provider + + Order ID + + Actions +
- - -
-
+ + + + {items.map(order => { + const currency = orderCurrency(order, locale); + const totalMinor = pickMinor( + order?.totalAmountMinor, + order?.totalAmount + ); + const totalFormatted = + totalMinor === null + ? '-' + : formatMoney(totalMinor, currency, locale); + + return ( + + + {formatDate(order.createdAt)} + + + + + {order.paymentStatus} + + + + + {totalFormatted} + + + + {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 aa508c77..e9a771cf 100644 --- a/frontend/app/[locale]/shop/admin/page.tsx +++ b/frontend/app/[locale]/shop/admin/page.tsx @@ -1,44 +1,66 @@ +// frontend/app/[locale]/shop/admin/page.tsx import { Link } from '@/i18n/routing'; + import { ShopAdminTopbar } from '@/components/shop/admin/shop-admin-topbar'; import { guardShopAdminPage } from '@/lib/auth/guard-shop-admin-page'; +export const dynamic = 'force-dynamic'; + export default async function ShopAdminHomePage() { await guardShopAdminPage(); + return ( <> -
-

Shop Admin

-

- Administrative tools for the merch shop. -

-
- +
+

-
- Products -
-
- Create, edit, activate, feature. -
- + Shop Admin +

+

+ Administrative tools for the merch shop. +

+
- -
- Orders -
-
- Review and manage orders. -
- -
-
+
+
    +
  • + +
    + Products +
    +
    + Create, edit, activate, feature. +
    + +
  • + +
  • + +
    + 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 686f1ab2..03ec21f9 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 @@ +// frontend/app/[locale]/shop/admin/products/[id]/edit/page.tsx 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'; @@ -10,7 +12,10 @@ import { products, productPrices } from '@/db/schema'; import type { CurrencyCode } from '@/lib/shop/currency'; import { currencyValues } from '@/lib/shop/currency'; +export const dynamic = 'force-dynamic'; + const paramsSchema = z.object({ id: z.string().uuid() }); + function parseMajorToMinor(value: string | number): number { const s = String(value).trim().replace(',', '.'); if (!/^\d+(\.\d{1,2})?$/.test(s)) { @@ -27,9 +32,10 @@ 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(); + if (!parsed.success) notFound(); const [product] = await db .select() @@ -37,7 +43,7 @@ export default async function EditProductPage({ .where(eq(products.id, parsed.data.id)) .limit(1); - if (!product) return notFound(); + if (!product) notFound(); const prices = await db .select({ @@ -73,26 +79,28 @@ export default async function EditProductPage({ return ( <> - +
+ +
); } diff --git a/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx b/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx index 78f292b7..1e2b8ccf 100644 --- a/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx +++ b/frontend/app/[locale]/shop/admin/products/_components/product-form.tsx @@ -1,6 +1,7 @@ +// frontend/app/[locale]/shop/admin/products/_components/product-form.tsx 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { CATEGORIES, COLORS, PRODUCT_TYPES, SIZES } from '@/lib/config/catalog'; @@ -40,6 +41,7 @@ type UiPriceRow = { price: string; originalPrice: string; }; + type SaleRuleDetails = { currency?: CurrencyCode; field?: string; @@ -130,6 +132,14 @@ export function ProductForm({ }: ProductFormProps) { const router = useRouter(); + const headingId = useId(); + const formErrorId = useId(); + const slugHelpId = useId(); + const slugErrorId = useId(); + const imageErrorId = useId(); + const usdOriginalErrorId = useId(); + const uahOriginalErrorId = useId(); + const [title, setTitle] = useState(initialValues?.title ?? ''); const [slug, setSlug] = useState( initialValues?.slug @@ -165,7 +175,7 @@ export function ProductForm({ ); const [imageFile, setImageFile] = useState(null); - const [existingImageUrl] = useState(initialValues?.imageUrl); + const existingImageUrl = initialValues?.imageUrl; const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -189,6 +199,7 @@ export function ProductForm({ () => prices.find(p => p.currency === 'UAH'), [prices] ); + const usdOriginalError = originalPriceErrors['USD']; const uahOriginalError = originalPriceErrors['UAH']; @@ -221,6 +232,7 @@ export function ProductForm({ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + setError(null); setSlugError(null); setImageError(null); @@ -264,6 +276,7 @@ export function ProductForm({ priceMinor: number; originalPriceMinor: number | null; }>; + try { minorPrices = effectivePrices.map(p => ({ currency: p.currency, @@ -319,6 +332,7 @@ export function ProductForm({ if (data.code === 'IMAGE_UPLOAD_FAILED' || data.field === 'image') { setImageError(data.error ?? 'Failed to upload image'); } + if (data.code === 'SALE_ORIGINAL_REQUIRED') { const details = data.details as SaleRuleDetails | undefined; const currency = details?.currency; @@ -350,6 +364,7 @@ export function ProductForm({ productId: productId ?? null, slug: slugValue, }); + setError( `Unexpected error while ${ mode === 'create' ? 'creating' : 'updating' @@ -360,14 +375,25 @@ export function ProductForm({ } }; + const describedBySlug = slugError + ? `${slugHelpId} ${slugErrorId}` + : slugHelpId; + return ( -
-

- {mode === 'create' ? 'Create new product' : 'Edit product'} -

+
+
+

+ {mode === 'create' ? 'Create new product' : 'Edit product'} +

+
{error ? ( -
+ ) : null} @@ -376,8 +402,9 @@ export function ProductForm({ className="mt-6 space-y-4" onSubmit={handleSubmit} encType="multipart/form-data" + aria-describedby={error ? formErrorId : undefined} > -
+
- + Auto-generated from title
{slugError ? ( -

{slugError}

+ ) : null}
-
+ -
-
Prices
+
+ + Prices +
-
-
+
+ USD (required) -
+
setPriceField('USD', 'originalPrice', e.target.value) } + aria-invalid={usdOriginalError ? true : undefined} + aria-describedby={ + usdOriginalError ? usdOriginalErrorId : undefined + } /> {usdOriginalError ? ( -

+

) : null}
-
+
-
-
+
+ UAH (optional) -
+
setPriceField('UAH', 'originalPrice', e.target.value) } + aria-invalid={uahOriginalError ? true : undefined} + aria-describedby={ + uahOriginalError ? uahOriginalErrorId : undefined + } /> {uahOriginalError ? ( -

+

) : null}
-
+

@@ -529,9 +593,12 @@ export function ProductForm({ product_prices for that currency, or checkout fails.

-
+ -
+
@@ -558,15 +627,19 @@ export function ProductForm({ setSku(event.target.value)} />
- + -
+
-
+ -
-
-