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/.env.example b/frontend/.env.example index 6f5fe296..4585bc49 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,40 +1,68 @@ +# --- Core / Environment +APP_ENV= +APP_URL= +NEXT_PUBLIC_SITE_URL= + +# --- Database DATABASE_URL= + +# --- Auth (app) AUTH_SECRET= -CLOUDINARY_CLOUD_NAME= +# --- OAuth: Google +GOOGLE_CLIENT_ID_DEVELOP= +GOOGLE_CLIENT_ID_LOCAL= +GOOGLE_CLIENT_ID_PROD= +GOOGLE_CLIENT_REDIRECT_URI_DEVELOP= +GOOGLE_CLIENT_REDIRECT_URI_LOCAL= +GOOGLE_CLIENT_REDIRECT_URI_PROD= +GOOGLE_CLIENT_SECRET_DEVELOP= +GOOGLE_CLIENT_SECRET_LOCAL= +GOOGLE_CLIENT_SECRET_PROD= + +# --- OAuth: GitHub +GITHUB_CLIENT_ID_DEVELOP= +GITHUB_CLIENT_ID_LOCAL= +GITHUB_CLIENT_ID_PROD= +GITHUB_CLIENT_REDIRECT_URI_DEVELOP= +GITHUB_CLIENT_REDIRECT_URI_LOCAL= +GITHUB_CLIENT_REDIRECT_URI_PROD= +GITHUB_CLIENT_SECRET_DEVELOP= +GITHUB_CLIENT_SECRET_LOCAL= +GITHUB_CLIENT_SECRET_PROD= + +# --- Cloudinary CLOUDINARY_API_KEY= CLOUDINARY_API_SECRET= +CLOUDINARY_CLOUD_NAME= CLOUDINARY_UPLOAD_FOLDER= - CLOUDINARY_URL= -ENABLE_ADMIN_API= +# --- Payments (Stripe) +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= PAYMENTS_ENABLED= -NEXT_PUBLIC_PAYMENTS_ENABLED= +# Options: test, live (defaults to test in development, live in production) +STRIPE_MODE= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= -STRIPE_MODE= -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +# --- Admin / Internal ops +ENABLE_ADMIN_API= +INTERNAL_JANITOR_MIN_INTERVAL_SECONDS= +INTERNAL_JANITOR_SECRET= +JANITOR_URL= -NEXT_PUBLIC_SITE_URL= -NEXT_PUBLIC_SITE_URL= +# --- Quiz +QUIZ_ENCRYPTION_KEY= +# --- Telegram TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -GOOGLE_CLIENT_REDIRECT_URI_LOCAL= -GOOGLE_CLIENT_REDIRECT_URI_DEVELOP= -GOOGLE_CLIENT_REDIRECT_URI_PROD= - -GITHUB_CLIENT_ID_DEVELOP= -GITHUB_CLIENT_SECRET_DEVELOP= -GITHUB_CLIENT_REDIRECT_URI_DEVELOP= - -APP_ENV= +# --- Email (Gmail SMTP) +EMAIL_FROM= +GMAIL_APP_PASSWORD= +GMAIL_USER= -INTERNAL_JANITOR_SECRET= -INTERNAL_JANITOR_MIN_INTERVAL_SECONDS=60 -JANITOR_URL= \ No newline at end of file +# --- Security +CSRF_SECRET= diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index 96f569b3..f7a3e20a 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -31,7 +31,15 @@ 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'; + 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..b575bd6d 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,16 @@ 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..007b076f 100644 --- a/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/[id]/page.tsx @@ -1,7 +1,8 @@ -import { Link } from '@/i18n/routing'; - import { notFound } from 'next/navigation'; + +import { Link } from '@/i18n/routing'; import { RefundButton } from './RefundButton'; + import { getAdminOrderDetail } from '@/db/queries/shop/admin-orders'; import { formatMoney, @@ -21,16 +22,19 @@ function pickMinor(minor: unknown, legacyMajor: unknown): number | null { } function orderCurrency( - order: { currency?: string | null } | null, + order: { currency?: string | null }, locale: string ): CurrencyCode { - const c = order?.currency ?? resolveCurrencyFromLocale(locale); + const c = order.currency ?? resolveCurrencyFromLocale(locale); return c === 'UAH' ? 'UAH' : 'USD'; } -function formatDateTime(value: Date | null | undefined) { +function formatDateTime( + value: Date | null | undefined, + locale: string +): string { if (!value) return '-'; - return value.toLocaleString(); + return value.toLocaleString(locale); } export default async function AdminOrderDetailPage({ @@ -39,7 +43,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 +54,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 +113,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,132 +123,165 @@ export default async function AdminOrderDetailPage({
Payment intent
-
+
{order.paymentIntentId ?? '-'}
Idempotency key
-
+
{order.idempotencyKey}
-
+
-
-
+
+

Stock / timestamps -

+ +
Created
- {formatDateTime(order.createdAt)} + {formatDateTime(order.createdAt, locale)}
+
Updated
- {formatDateTime(order.updatedAt)} + {formatDateTime(order.updatedAt, locale)}
+
Stock restored
{order.stockRestored ? 'Yes' : 'No'}
+
Restocked at
- {formatDateTime(order.restockedAt)} + {formatDateTime(order.restockedAt, locale)}
-
-
- -
- - - - - - - - - - - - {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..33366ef0 100644 --- a/frontend/app/[locale]/shop/admin/orders/page.tsx +++ b/frontend/app/[locale]/shop/admin/orders/page.tsx @@ -10,15 +10,15 @@ 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'; +import { CSRF_FORM_FIELD, issueCsrfToken } from '@/lib/security/csrf'; +import { parsePage } from '@/lib/pagination'; export const dynamic = 'force-dynamic'; -const PAGE_SIZE = 50; +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; -} +type AdminOrdersResult = Awaited>; +type AdminOrderRow = AdminOrdersResult['items'][number]; function pickMinor(minor: unknown, legacyMajor: unknown): number | null { if (typeof minor === 'number') return minor; @@ -26,13 +26,14 @@ function pickMinor(minor: unknown, legacyMajor: unknown): number | null { return fromDbMoney(legacyMajor); } -function orderCurrency(order: any, locale: string): CurrencyCode { - return (order?.currency ?? resolveCurrencyFromLocale(locale)) as CurrencyCode; +function orderCurrency(order: AdminOrderRow, locale: string): CurrencyCode { + const c = order.currency ?? resolveCurrencyFromLocale(locale); + return c === 'UAH' ? 'UAH' : 'USD'; } -function formatDate(value: Date | null | undefined) { +function formatDate(value: Date | null | undefined, locale: string): string { if (!value) return '-'; - return value.toLocaleDateString(); + return value.toLocaleDateString(locale); } export default async function AdminOrdersPage({ @@ -43,8 +44,10 @@ export default async function AdminOrdersPage({ searchParams: Promise<{ page?: string }>; }) { await guardShopAdminPage(); + const { locale } = await params; const sp = await searchParams; + const csrfToken = issueCsrfToken('admin:orders:reconcile-stale'); const page = parsePage(sp.page); const offset = (page - 1) * PAGE_SIZE; @@ -61,11 +64,21 @@ 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.length === 0 ? ( + + + No orders yet. + + + ) : ( + 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, locale)} + + + + + {order.paymentStatus} + + + + + {totalFormatted} + + + + {order.itemCount} + + + {order.paymentProvider} + + + + {order.id} + + + + + View + + + + ); + }) + )} + + + +
+ +
+ + ); } 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..317dcc10 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,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { CATEGORIES, COLORS, PRODUCT_TYPES, SIZES } from '@/lib/config/catalog'; @@ -40,6 +40,7 @@ type UiPriceRow = { price: string; originalPrice: string; }; + type SaleRuleDetails = { currency?: CurrencyCode; field?: string; @@ -130,6 +131,23 @@ export function ProductForm({ }: ProductFormProps) { const router = useRouter(); + const idBase = useMemo(() => { + const pid = + typeof productId === 'string' && productId.trim().length + ? productId.trim() + : 'new'; + return `product-form-${mode}-${pid}`; + }, [mode, productId]); + + const headingId = `${idBase}-heading`; + const formErrorId = `${idBase}-form-error`; + const slugHelpId = `${idBase}-slug-help`; + const slugErrorId = `${idBase}-slug-error`; + const imageErrorId = `${idBase}-image-error`; + const usdOriginalErrorId = `${idBase}-usd-original-error`; + const uahOriginalErrorId = `${idBase}-uah-original-error`; + + const hydratedKeyRef = useRef(null); const [title, setTitle] = useState(initialValues?.title ?? ''); const [slug, setSlug] = useState( initialValues?.slug @@ -165,7 +183,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); @@ -175,11 +193,66 @@ export function ProductForm({ Partial> >({}); + // 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(() => { - setSlug(localSlugify(title)); - }, [title]); - - const slugValue = useMemo(() => slug || localSlugify(title), [slug, title]); + if (mode !== 'edit') { + hydratedKeyRef.current = null; + return; + } + if (!initialValues) return; + + const key = + (typeof productId === 'string' && productId.trim().length + ? productId + : null) ?? + (typeof initialValues.slug === 'string' && + initialValues.slug.trim().length + ? initialValues.slug + : null) ?? + (typeof initialValues.title === 'string' && + initialValues.title.trim().length + ? initialValues.title + : null); + + if (!key) return; + + 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); + setOriginalPriceErrors({}); + setIsSubmitting(false); + setImageFile(null); + + if (typeof initialValues.title === 'string') setTitle(initialValues.title); + if (typeof initialValues.slug === 'string') + setSlug(localSlugify(initialValues.slug)); + + setPrices(ensureUiPriceRows((initialValues as any)?.prices)); + setCategory(initialValues.category ?? ''); + setType(initialValues.type ?? ''); + setSelectedColors(initialValues.colors ?? []); + setSelectedSizes(initialValues.sizes ?? []); + setStock( + typeof initialValues.stock === 'number' ? String(initialValues.stock) : '' + ); + setSku(initialValues.sku ?? ''); + setBadge(initialValues.badge ?? 'NONE'); + setDescription(initialValues.description ?? ''); + setIsActive(initialValues.isActive ?? true); + setIsFeatured(initialValues.isFeatured ?? false); + hydratedKeyRef.current = key; + }, [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. + return localSlugify(title); + }, [mode, slug, title]); const usdRow = useMemo( () => prices.find(p => p.currency === 'USD'), @@ -189,6 +262,7 @@ export function ProductForm({ () => prices.find(p => p.currency === 'UAH'), [prices] ); + const usdOriginalError = originalPriceErrors['USD']; const uahOriginalError = originalPriceErrors['UAH']; @@ -221,6 +295,7 @@ export function ProductForm({ const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + setError(null); setSlugError(null); setImageError(null); @@ -264,6 +339,7 @@ export function ProductForm({ priceMinor: number; originalPriceMinor: number | null; }>; + try { minorPrices = effectivePrices.map(p => ({ currency: p.currency, @@ -319,6 +395,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 +427,7 @@ export function ProductForm({ productId: productId ?? null, slug: slugValue, }); + setError( `Unexpected error while ${ mode === 'create' ? 'creating' : 'updating' @@ -360,14 +438,28 @@ 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 +468,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 +659,12 @@ export function ProductForm({ product_prices for that currency, or checkout fails.

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