diff --git a/CHANGELOG.md b/CHANGELOG.md index 210eed6c..5bb78ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -715,3 +715,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Improved client-render guards using stable subscription pattern - Reduced hydration inconsistencies in production + +## [1.0.4] - 2026-02-26 + +### Added + +- Shop shipping foundation: + - Nova Poshta delivery support (cities, warehouses, courier) + - Checkout shipping persistence with PII-safe snapshot + - Async shipment label creation workflow + - Admin shipping actions (retry label, mark shipped/delivered) + - Shipping status and tracking in order details + +### Changed + +- Performance & cost optimization (Vercel): + - Blog ISR enabled (revalidate: 3600) + - Sanity CDN enabled globally + - Cached blog categories via unstable_cache + - Notification polling replaced with visibility-based refresh + - Analytics runs only in production + - Speed Insights removed + +### Fixed + +- Reduced unnecessary layout revalidation after notification actions +- Improved cache consistency for blog content and categories + +### Performance & Infrastructure + +- Lower Vercel Function Invocations and CPU usage +- Reduced origin data transfer for blog content +- Improved overall runtime efficiency diff --git a/frontend/.env.example b/frontend/.env.example index fde0d20b..abb96456 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,10 +7,11 @@ NEXT_PUBLIC_SITE_URL= # --- Database DATABASE_URL= +DATABASE_URL_LOCAL= # --- Upstash Redis (REST) -UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +UPSTASH_REDIS_REST_URL= # --- Auth (app) AUTH_SECRET= @@ -46,25 +47,73 @@ CLOUDINARY_URL= # --- Payments (Stripe) NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= -PAYMENTS_ENABLED= # Options: test, live (defaults to test in development, live in production) STRIPE_MODE= +STRIPE_PAYMENTS_ENABLED= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= +# --- Payments (Monobank) +# Optional; set explicitly in production for clarity +MONO_API_BASE= +MONO_INVOICE_TIMEOUT_MS= + +# Required for Monobank checkout/webhooks +MONO_MERCHANT_TOKEN= +MONO_PUBLIC_KEY= + +# Optional webhook/runtime tuning (defaults in code if omitted) +MONO_REFUND_ENABLED=0 +MONO_WEBHOOK_CLAIM_TTL_MS= +MONO_WEBHOOK_MODE= + +PAYMENTS_ENABLED= + +# --- Shipping (Nova Poshta) +# Toggles (optional; defaults are handled in code) +SHOP_SHIPPING_ENABLED=0 +SHOP_SHIPPING_NP_ENABLED=0 + +# Retention (optional; days, used for cleanup/retention policies) +SHOP_SHIPPING_RETENTION_DAYS= + +# Required when shipping is enabled (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1). +# If shipping is enabled without required NP config, app throws NovaPoshtaConfigError at runtime. +# Optional if code has a default; set explicitly in production for clarity +NP_API_BASE= +NP_API_KEY= +NP_SENDER_WAREHOUSE_REF= +NP_SENDER_CITY_REF= +NP_SENDER_CONTACT_REF= +NP_SENDER_NAME= +NP_SENDER_PHONE= +NP_SENDER_REF= + +# Optional tuning (override only if needed; otherwise code defaults apply) +NP_MAX_RETRIES= +NP_RETRY_DELAY_MS= +NP_TIMEOUT_MS= + # --- Admin / Internal ops ENABLE_ADMIN_API= INTERNAL_JANITOR_MIN_INTERVAL_SECONDS= INTERNAL_JANITOR_SECRET= JANITOR_URL= +# Optional internal/admin runtime secrets & tuning (used by internal endpoints/jobs) +INTERNAL_SECRET= +JANITOR_TIMEOUT_MS= + +# Optional instance IDs for webhook multi-instance diagnostics/claiming +STRIPE_WEBHOOK_INSTANCE_ID= +WEBHOOK_INSTANCE_ID= + # --- Quiz QUIZ_ENCRYPTION_KEY= # --- Web3Forms (feedback form) -NEXT_PUBLIC_WEB3FORMS_KEY= - GITHUB_SPONSORS_TOKEN= +NEXT_PUBLIC_WEB3FORMS_KEY= # --- Telegram TELEGRAM_BOT_TOKEN= @@ -75,6 +124,13 @@ EMAIL_FROM= GMAIL_APP_PASSWORD= GMAIL_USER= +# --- Shop / Internal +# Optional public/base URL used by shop services/links +SHOP_BASE_URL= + +# Required for signed shop status tokens (if status endpoint/token flow is enabled) +SHOP_STATUS_TOKEN_SECRET= + # --- Security CSRF_SECRET= @@ -107,6 +163,4 @@ TRUST_FORWARDED_HEADERS=0 # emergency switch RATE_LIMIT_DISABLED=0 -GROQ_API_KEY= - -NEXT_PUBLIC_WEB3FORMS_KEY= \ No newline at end of file +GROQ_API_KEY= \ No newline at end of file diff --git a/frontend/actions/notifications.ts b/frontend/actions/notifications.ts index e177eef2..a71a6570 100644 --- a/frontend/actions/notifications.ts +++ b/frontend/actions/notifications.ts @@ -1,7 +1,6 @@ 'use server'; import { and,desc, eq } from 'drizzle-orm'; -import { revalidatePath } from 'next/cache'; import { db } from '@/db'; import { notifications } from '@/db/schema/notifications'; @@ -39,7 +38,6 @@ export async function markAsRead(notificationId: string) { ) ); - revalidatePath('/', 'layout'); return { success: true }; } catch (error) { console.error('Failed to mark notification as read:', error); @@ -57,7 +55,6 @@ export async function markAllAsRead() { .set({ isRead: true }) .where(and(eq(notifications.userId, session.id), eq(notifications.isRead, false))); - revalidatePath('/', 'layout'); return { success: true }; } catch (error) { console.error('Failed to mark all notifications as read:', error); @@ -86,7 +83,6 @@ export async function createNotification(data: { }) .returning(); - revalidatePath('/', 'layout'); return result; } catch (error) { console.error('Failed to create notification:', error); diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx index 52cfe22a..e934032c 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx @@ -7,9 +7,10 @@ import { useId, useState, useTransition } from 'react'; type Props = { orderId: string; disabled: boolean; + csrfToken: string; }; -export function RefundButton({ orderId, disabled }: Props) { +export function RefundButton({ orderId, disabled, csrfToken }: Props) { const router = useRouter(); const t = useTranslations('shop.admin.refund'); const [isPending, startTransition] = useTransition(); @@ -24,7 +25,10 @@ export function RefundButton({ orderId, disabled }: Props) { res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, { method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, }); } catch (err) { const msg = diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx new file mode 100644 index 00000000..9b66b6c5 --- /dev/null +++ b/frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useId, useState, useTransition } from 'react'; + +type ActionName = 'retry_label_creation' | 'mark_shipped' | 'mark_delivered'; + +type Props = { + orderId: string; + csrfToken: string; + shippingStatus: string | null; + shipmentStatus: string | null; +}; + +function actionEnabled(args: { + action: ActionName; + shippingStatus: string | null; + shipmentStatus: string | null; +}): boolean { + if (args.action === 'retry_label_creation') { + return ( + args.shipmentStatus === 'failed' || args.shipmentStatus === 'needs_attention' + ); + } + if (args.action === 'mark_shipped') { + return args.shippingStatus === 'label_created'; + } + return args.shippingStatus === 'shipped'; +} + +export function ShippingActions({ + orderId, + csrfToken, + shippingStatus, + shipmentStatus, +}: Props) { + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const errorId = useId(); + + async function runAction(action: ActionName) { + setError(null); + + let res: Response; + try { + res = await fetch(`/api/shop/admin/orders/${orderId}/shipping`, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': csrfToken, + }, + body: JSON.stringify({ action }), + }); + } catch (err) { + const msg = + err instanceof Error && err.message ? err.message : 'NETWORK_ERROR'; + setError(msg); + return; + } + + let json: any = null; + try { + json = await res.json(); + } catch { + // ignore + } + + if (!res.ok) { + setError(json?.code ?? json?.message ?? `HTTP_${res.status}`); + return; + } + + startTransition(() => { + router.refresh(); + }); + } + + const retryEnabled = actionEnabled({ + action: 'retry_label_creation', + shippingStatus, + shipmentStatus, + }); + const shippedEnabled = actionEnabled({ + action: 'mark_shipped', + shippingStatus, + shipmentStatus, + }); + const deliveredEnabled = actionEnabled({ + action: 'mark_delivered', + shippingStatus, + shipmentStatus, + }); + + return ( +
+
+ + + + + +
+ + {error ? ( + + ) : null} +
+ ); +} diff --git a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx index 365babb2..a38f575f 100644 --- a/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx +++ b/frontend/app/[locale]/admin/shop/orders/[id]/page.tsx @@ -1,279 +1,407 @@ +import 'server-only'; + +import { eq } from 'drizzle-orm'; import { Metadata } from 'next'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; -import { getAdminOrderDetail } from '@/db/queries/shop/admin-orders'; +import { db } from '@/db'; +import { orderItems, orders } from '@/db/schema'; import { Link } from '@/i18n/routing'; -import { - type CurrencyCode, - formatMoney, - resolveCurrencyFromLocale, -} from '@/lib/shop/currency'; +import { getCurrentUser } from '@/lib/auth'; +import { logError } from '@/lib/logging'; +import { type CurrencyCode, formatMoney } from '@/lib/shop/currency'; import { fromDbMoney } from '@/lib/shop/money'; - -import { RefundButton } from './RefundButton'; +import { + SHOP_FOCUS, + SHOP_LINK_BASE, + SHOP_LINK_MD, + SHOP_NAV_LINK_BASE, +} from '@/lib/shop/ui-classes'; +import { cn } from '@/lib/utils'; +import { orderIdParamSchema } from '@/lib/validation/shop'; export const metadata: Metadata = { - title: 'Admin Order | DevLovers', - description: 'Review and manage order, including refunds and status checks.', + title: 'Order Details | DevLovers', + description: 'Order details, items, totals, and current status.', +}; +export const dynamic = 'force-dynamic'; + +type OrderCurrency = (typeof orders.$inferSelect)['currency']; +type OrderPaymentStatus = (typeof orders.$inferSelect)['paymentStatus']; +type OrderPaymentProvider = (typeof orders.$inferSelect)['paymentProvider']; + +type OrderDetail = { + id: string; + userId: string | null; + totalAmount: string; + currency: OrderCurrency; + paymentStatus: OrderPaymentStatus; + paymentProvider: OrderPaymentProvider; + paymentIntentId: string | null; + shippingStatus: string | null; + trackingNumber: string | null; + stockRestored: boolean; + restockedAt: string | null; + idempotencyKey: string; + createdAt: string; + updatedAt: string; + items: Array<{ + id: string; + productId: string; + productTitle: string | null; + productSlug: string | null; + productSku: string | null; + quantity: number; + unitPrice: string; + lineTotal: string; + }>; }; -function pickMinor(minor: unknown, legacyMajor: unknown): number | null { - if (typeof minor === 'number') return minor; - if (legacyMajor === null || legacyMajor === undefined) return null; - return fromDbMoney(legacyMajor); +function safeFormatMoneyMajor( + major: string, + currency: CurrencyCode, + locale: string +): string { + try { + return formatMoney(fromDbMoney(major), currency, locale); + } catch { + return `${major} ${currency}`; + } } -function orderCurrency( - order: { currency?: string | null }, - locale: string -): CurrencyCode { - const c = order.currency ?? resolveCurrencyFromLocale(locale); - return c === 'UAH' ? 'UAH' : 'USD'; +function safeFormatDateTime(iso: string, dtf: Intl.DateTimeFormat): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return dtf.format(d); } -function formatDateTime( - value: Date | null | undefined, - locale: string -): string { - if (!value) return '-'; - return value.toLocaleString(locale); +function toOrderItem( + item: { + id: string | null; + productId: string | null; + productTitle: string | null; + productSlug: string | null; + productSku: string | null; + quantity: number | null; + unitPrice: string | null; + lineTotal: string | null; + } | null +): OrderDetail['items'][number] | null { + if (!item || !item.id) return null; + + if ( + !item.productId || + item.quantity === null || + !item.unitPrice || + !item.lineTotal + ) { + throw new Error('Corrupt order item row: required columns are null'); + } + + return { + id: item.id, + productId: item.productId, + productTitle: item.productTitle, + productSlug: item.productSlug, + productSku: item.productSku, + quantity: item.quantity, + unitPrice: item.unitPrice, + lineTotal: item.lineTotal, + }; } -export default async function AdminOrderDetailPage({ +export default async function OrderDetailPage({ params, }: { params: Promise<{ locale: string; id: string }>; }) { const { locale, id } = await params; - - const order = await getAdminOrderDetail(id); - if (!order) notFound(); - - const canRefund = - order.paymentProvider === 'stripe' && - 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); + const t = await getTranslations('shop.orders.detail'); + + const user = await getCurrentUser(); + if (!user) { + redirect( + `/${locale}/login?returnTo=${encodeURIComponent( + `/${locale}/admin/shop/orders/${id}` + )}` + ); + } + + const parsed = orderIdParamSchema.safeParse({ id }); + if (!parsed.success) notFound(); + + const isAdmin = user.role === 'admin'; + if (!isAdmin) notFound(); + + let order: OrderDetail; + + const whereClause = eq(orders.id, parsed.data.id); + + const rows = await (async () => { + try { + return await db + .select({ + order: { + id: orders.id, + userId: orders.userId, + totalAmount: orders.totalAmount, + currency: orders.currency, + paymentStatus: orders.paymentStatus, + paymentProvider: orders.paymentProvider, + paymentIntentId: orders.paymentIntentId, + shippingStatus: orders.shippingStatus, + trackingNumber: orders.trackingNumber, + stockRestored: orders.stockRestored, + restockedAt: orders.restockedAt, + idempotencyKey: orders.idempotencyKey, + createdAt: orders.createdAt, + updatedAt: orders.updatedAt, + }, + item: { + id: orderItems.id, + productId: orderItems.productId, + productTitle: orderItems.productTitle, + productSlug: orderItems.productSlug, + productSku: orderItems.productSku, + quantity: orderItems.quantity, + unitPrice: orderItems.unitPrice, + lineTotal: orderItems.lineTotal, + }, + }) + .from(orders) + .leftJoin(orderItems, eq(orderItems.orderId, orders.id)) + .where(whereClause) + .orderBy(orderItems.id); + } catch (error) { + logError('Admin order detail page failed', error); + throw new Error('ORDER_DETAIL_LOAD_FAILED'); + } + })(); + + if (rows.length === 0) notFound(); + + try { + const base = rows[0]!.order; + + const items = rows + .map(r => toOrderItem(r.item)) + .filter((i): i is NonNullable => i !== null); + + order = { + ...base, + createdAt: base.createdAt.toISOString(), + updatedAt: base.updatedAt.toISOString(), + restockedAt: base.restockedAt ? base.restockedAt.toISOString() : null, + items, + }; + } catch (error) { + logError('Admin order detail page failed', error); + throw new Error('ORDER_DETAIL_LOAD_FAILED'); + } + + const currency: CurrencyCode = order.currency === 'UAH' ? 'UAH' : 'USD'; + const dtf = new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + }); + + const totalFormatted = safeFormatMoneyMajor( + order.totalAmount, + currency, + locale + ); + const createdFormatted = safeFormatDateTime(order.createdAt, dtf); + const restockedFormatted = order.restockedAt + ? safeFormatDateTime(order.restockedAt, dtf) + : '—'; + const NAV_LINK = cn(SHOP_NAV_LINK_BASE, 'text-lg', SHOP_FOCUS); + + const PRODUCT_LINK = cn( + SHOP_LINK_BASE, + SHOP_LINK_MD, + SHOP_FOCUS, + 'truncate', + '!underline !decoration-2 !underline-offset-4' + ); return ( -
-
+
+
-

- Order +

+ {t('title')}

-

+

{order.id} -

+
-
- - Back +
+ + {t('shop')} + +
-
-

- Summary -

- -
-
-
Payment status
-
{order.paymentStatus}
-
- -
-
Total
-
{totalFormatted}
-
- -
-
Provider
-
{order.paymentProvider}
-
- -
-
Payment intent
-
- {order.paymentIntentId ?? '-'} -
-
- -
-
Idempotency key
-
- {order.idempotencyKey} -
-
-
-
- -
-

- Stock / timestamps -

+

+ {t('orderSummary')} +

-
-
-
Created
-
- {formatDateTime(order.createdAt, locale)} -
-
- -
-
Updated
-
- {formatDateTime(order.updatedAt, locale)} -
-
- -
-
Stock restored
-
- {order.stockRestored ? 'Yes' : 'No'} -
-
- -
-
Restocked at
-
- {formatDateTime(order.restockedAt, locale)} -
-
-
-
+
+
+
{t('total')}
+
{totalFormatted}
+
+ +
+
+ {t('paymentStatus')} +
+
+ {String(order.paymentStatus)} +
+
+ +
+
+ {t('shippingStatus')} +
+
+ {order.shippingStatus ?? '-'} +
+
+ +
+
+ {t('trackingNumber')} +
+
+ {order.trackingNumber ?? '-'} +
+
+ +
+
{t('created')}
+
{createdFormatted}
+
+ +
+
{t('provider')}
+
{String(order.paymentProvider)}
+
+
+ +
+
+
+ {t('paymentReference')} +
+
+ {order.paymentIntentId ?? '—'} +
+
+
+
+ {t('idempotencyKey')} +
+
{order.idempotencyKey}
+
+
+ +
+
+
+ {t('stockRestored')} +
+
+ {order.stockRestored ? t('yes') : t('no')} +
+
+
+
+ {t('restockedAt')} +
+
{restockedFormatted}
+
+
-
-

- Order items -

- -
- - - - - - - - - - - - - - {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 ( - - - - - - - - - - ); - })} - - {order.items.length === 0 ? ( - - - - ) : null} - -
Line items for this order
- Product - - Qty - - Unit - - Line total -
-
- {item.productTitle ?? '-'} -
-
- - {item.productSlug ?? '-'} - - {item.productSku ? ( - · {item.productSku} - ) : null} -
-
- {item.quantity} - - {unitFormatted} - - {lineFormatted} -
- No items found for this order. -
+
+
+

+ {t('items')} +

+ +
    + {order.items.map(it => ( +
  • +
    +
    + {it.productSlug ? ( + + {it.productTitle ?? + it.productSlug ?? + it.productSku ?? + it.productId} + + ) : ( +
    + {it.productTitle ?? it.productSku ?? it.productId} +
    + )} + +
    + {it.productSku + ? t('sku', { sku: it.productSku }) + : t('product', { productId: it.productId })} +
    +
    + +
    +
    +
    {t('quantity')}
    +
    Qty: {it.quantity}
    +
    +
    +
    {t('unitPrice')}
    +
    + Unit:{' '} + {safeFormatMoneyMajor(it.unitPrice, currency, locale)} +
    +
    +
    +
    {t('lineTotal')}
    +
    + Line:{' '} + {safeFormatMoneyMajor(it.lineTotal, currency, locale)} +
    +
    +
    +
    +
  • + ))} +
); diff --git a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx index d1f9ab79..95b3c13c 100644 --- a/frontend/app/[locale]/blog/[slug]/PostDetails.tsx +++ b/frontend/app/[locale]/blog/[slug]/PostDetails.tsx @@ -9,8 +9,6 @@ import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; -export const revalidate = 0; - type SocialLink = { _key?: string; platform?: string; @@ -383,18 +381,14 @@ export default async function PostDetails({ const slugParam = String(slug || '').trim(); if (!slugParam) return notFound(); - const post: Post | null = await client - .withConfig({ useCdn: false }) - .fetch(query, { - slug: slugParam, - locale, - }); - const recommendedAll: Post[] = await client - .withConfig({ useCdn: false }) - .fetch(recommendedQuery, { - slug: slugParam, - locale, - }); + const post: Post | null = await client.fetch(query, { + slug: slugParam, + locale, + }); + const recommendedAll: Post[] = await client.fetch(recommendedQuery, { + slug: slugParam, + locale, + }); const recommendedPosts = seededShuffle( recommendedAll, hashString(slugParam) diff --git a/frontend/app/[locale]/blog/[slug]/page.tsx b/frontend/app/[locale]/blog/[slug]/page.tsx index 1b8be81f..377b9354 100644 --- a/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/frontend/app/[locale]/blog/[slug]/page.tsx @@ -4,6 +4,8 @@ import { client } from '@/client'; import PostDetails from './PostDetails'; +export const revalidate = 3600; + export async function generateStaticParams() { const slugs = await client.fetch( groq`*[_type == "post" && defined(slug.current)][].slug.current` diff --git a/frontend/app/[locale]/blog/category/[category]/page.tsx b/frontend/app/[locale]/blog/category/[category]/page.tsx index 4746431e..8db74901 100644 --- a/frontend/app/[locale]/blog/category/[category]/page.tsx +++ b/frontend/app/[locale]/blog/category/[category]/page.tsx @@ -11,7 +11,7 @@ import { Link } from '@/i18n/routing'; import { formatBlogDate } from '@/lib/blog/date'; import { shouldBypassImageOptimization } from '@/lib/blog/image'; -export const revalidate = 0; +export const revalidate = 3600; type Author = { name?: string; @@ -50,9 +50,7 @@ export default async function BlogCategoryPage({ const t = await getTranslations({ locale, namespace: 'blog' }); const tNav = await getTranslations({ locale, namespace: 'navigation' }); const categoryKey = String(category || '').toLowerCase(); - const categories: Category[] = await client - .withConfig({ useCdn: false }) - .fetch(categoriesQuery); + const categories: Category[] = await client.fetch(categoriesQuery); const matchedCategory = categories.find( item => slugify(item.title) === categoryKey ); @@ -61,7 +59,7 @@ export default async function BlogCategoryPage({ const categoryTitle = matchedCategory.title; const categoryDisplay = getCategoryLabel(categoryTitle, t); - const posts: Post[] = await client.withConfig({ useCdn: false }).fetch( + const posts: Post[] = await client.fetch( groq` *[_type == "post" && defined(slug.current) && $category in categories[]->title] | order(coalesce(publishedAt, _createdAt) desc) { diff --git a/frontend/app/[locale]/blog/page.tsx b/frontend/app/[locale]/blog/page.tsx index 7e5b2506..a0a159d6 100644 --- a/frontend/app/[locale]/blog/page.tsx +++ b/frontend/app/[locale]/blog/page.tsx @@ -1,5 +1,4 @@ import groq from 'groq'; -import { unstable_noStore as noStore } from 'next/cache'; import { getTranslations } from 'next-intl/server'; import { client } from '@/client'; @@ -7,7 +6,7 @@ import BlogFilters from '@/components/blog/BlogFilters'; import { BlogPageHeader } from '@/components/blog/BlogPageHeader'; import { DynamicGridBackground } from '@/components/shared/DynamicGridBackground'; -export const revalidate = 0; +export const revalidate = 3600; export async function generateMetadata({ params, @@ -25,19 +24,13 @@ export async function generateMetadata({ export default async function BlogPage({ params, - searchParams, }: { params: Promise<{ locale: string }>; - searchParams?: Promise<{ [key: string]: string | string[] | undefined }>; }) { - noStore(); const { locale } = await params; const t = await getTranslations({ locale, namespace: 'blog' }); - const sp = searchParams ? await searchParams : undefined; - const authorParam = typeof sp?.author === 'string' ? sp.author.trim() : ''; - const hasAuthorFilter = authorParam.length > 0; - const posts = await client.withConfig({ useCdn: false }).fetch( + const posts = await client.fetch( groq` *[_type == "post" && defined(slug.current)] | order(coalesce(publishedAt, _createdAt) desc) { @@ -74,7 +67,7 @@ export default async function BlogPage({ `, { locale } ); - const categories = await client.withConfig({ useCdn: false }).fetch( + const categories = await client.fetch( groq` *[_type == "category"] | order(orderRank asc) { _id, @@ -87,9 +80,7 @@ export default async function BlogPage({ return (
- {!hasAuthorFilter && ( - - )} + + client.fetch>(groq` + *[_type == "category"] | order(orderRank asc) { + _id, + title + } + `), + ['blog-categories'], + { revalidate: 3600, tags: ['blog-categories'] } +); + export default async function LocaleLayout({ children, params, @@ -30,16 +43,7 @@ export default async function LocaleLayout({ const messages = await getMessages({ locale }); const user = await getCurrentUser(); - const blogCategories: Array<{ _id: string; title: string }> = await client - .withConfig({ useCdn: false }) - .fetch( - groq` - *[_type == "category"] | order(orderRank asc) { - _id, - title - } - ` - ); + const blogCategories = await getCachedBlogCategories(); const userExists = Boolean(user); const enableAdmin = diff --git a/frontend/app/[locale]/shop/cart/CartPageClient.tsx b/frontend/app/[locale]/shop/cart/CartPageClient.tsx index 2f2ef7ed..5bffd616 100644 --- a/frontend/app/[locale]/shop/cart/CartPageClient.tsx +++ b/frontend/app/[locale]/shop/cart/CartPageClient.tsx @@ -9,8 +9,14 @@ import { useEffect, useState } from 'react'; import { Loader } from '@/components/shared/Loader'; import { useCart } from '@/components/shop/CartProvider'; import { Link, useRouter } from '@/i18n/routing'; +import { + buildCheckoutShippingPayload, + type CheckoutDeliveryMethodCode, + type ShippingAvailabilityReasonCode, +} from '@/lib/services/shop/shipping/checkout-payload'; import { formatMoney } from '@/lib/shop/currency'; import { generateIdempotencyKey } from '@/lib/shop/idempotency'; +import { localeToCountry } from '@/lib/shop/locale'; import { SHOP_CHIP_BORDER_HOVER, SHOP_CHIP_INTERACTIVE, @@ -89,6 +95,29 @@ type OrdersSummaryState = { latestOrderId: string | null; }; +type ShippingMethod = { + provider: 'nova_poshta'; + methodCode: CheckoutDeliveryMethodCode; + title: string; +}; + +type ShippingCity = { + ref: string; + nameUa: string; +}; + +type ShippingWarehouse = { + ref: string; + name: string; + address: string | null; +}; + +function isWarehouseMethod( + methodCode: CheckoutDeliveryMethodCode | null +): boolean { + return methodCode === 'NP_WAREHOUSE' || methodCode === 'NP_LOCKER'; +} + export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const { cart, updateQuantity, removeFromCart } = useCart(); const router = useRouter(); @@ -114,6 +143,40 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { }) ); const [isClientReady, setIsClientReady] = useState(false); + const [shippingMethods, setShippingMethods] = useState([]); + const [shippingMethodsLoading, setShippingMethodsLoading] = useState(true); + const [shippingAvailable, setShippingAvailable] = useState(true); + const [shippingReasonCode, setShippingReasonCode] = + useState(null); + const [selectedShippingMethod, setSelectedShippingMethod] = + useState(null); + + const [cityQuery, setCityQuery] = useState(''); + const [cityOptions, setCityOptions] = useState([]); + const [selectedCityRef, setSelectedCityRef] = useState(null); + const [selectedCityName, setSelectedCityName] = useState(null); + const [citiesLoading, setCitiesLoading] = useState(false); + + const [warehouseQuery, setWarehouseQuery] = useState(''); + const [warehouseOptions, setWarehouseOptions] = useState( + [] + ); + const [selectedWarehouseRef, setSelectedWarehouseRef] = useState< + string | null + >(null); + const [selectedWarehouseName, setSelectedWarehouseName] = useState< + string | null + >(null); + const [warehousesLoading, setWarehousesLoading] = useState(false); + + const [courierAddressLine1, setCourierAddressLine1] = useState(''); + const [courierAddressLine2, setCourierAddressLine2] = useState(''); + const [recipientName, setRecipientName] = useState(''); + const [recipientPhone, setRecipientPhone] = useState(''); + const [recipientEmail, setRecipientEmail] = useState(''); + const [recipientComment, setRecipientComment] = useState(''); + + const [deliveryUiError, setDeliveryUiError] = useState(null); useEffect(() => { setIsClientReady(true); @@ -126,6 +189,63 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { const canUseStripe = stripeEnabled; const canUseMonobank = monobankEnabled && isUahCheckout; const hasSelectableProvider = canUseStripe || canUseMonobank; + const country = localeToCountry(locale); + const shippingUnavailableHardBlock = + shippingReasonCode === 'SHOP_SHIPPING_DISABLED' || + shippingReasonCode === 'NP_DISABLED' || + shippingReasonCode === 'COUNTRY_NOT_SUPPORTED' || + shippingReasonCode === 'CURRENCY_NOT_SUPPORTED' || + shippingReasonCode === 'INTERNAL_ERROR'; + const isWarehouseSelectionMethod = isWarehouseMethod(selectedShippingMethod); + const safeT = (key: string, fallback: string) => { + try { + return t(key as any); + } catch { + return fallback; + } + }; + + const SHIPPING_AVAILABILITY_REASON_TO_T_KEY: Record = { + SHOP_SHIPPING_DISABLED: 'delivery.unavailable.shopShippingDisabled', + NP_DISABLED: 'delivery.unavailable.npDisabled', + COUNTRY_NOT_SUPPORTED: 'delivery.unavailable.countryNotSupported', + CURRENCY_NOT_SUPPORTED: 'delivery.unavailable.currencyNotSupported', + INTERNAL_ERROR: 'delivery.unavailable.internalError', + }; + + const resolveShippingUnavailableText = ( + code: ShippingAvailabilityReasonCode | null + ): string | null => { + if (!code || code === 'OK') return null; + const key = + SHIPPING_AVAILABILITY_REASON_TO_T_KEY[String(code)] ?? + 'delivery.unavailableFallback'; + return safeT(key, String(code)); + }; + + const SHIPPING_PAYLOAD_ERROR_CODE_TO_T_KEY: Record = { + SHIPPING_METHOD_REQUIRED: 'delivery.validation.methodRequired', + CITY_REQUIRED: 'delivery.validation.cityRequired', + WAREHOUSE_REQUIRED: 'delivery.validation.warehouseRequired', + ADDRESS_REQUIRED: 'delivery.validation.addressRequired', + RECIPIENT_NAME_REQUIRED: 'delivery.validation.recipientNameRequired', + RECIPIENT_PHONE_REQUIRED: 'delivery.validation.recipientPhoneRequired', + RECIPIENT_EMAIL_INVALID: 'delivery.validation.recipientEmailInvalid', + }; + + const resolveShippingPayloadErrorText = (result: unknown): string => { + const code = + result && typeof result === 'object' && 'code' in result + ? typeof (result as any).code === 'string' + ? String((result as any).code) + : null + : null; + + const key = code ? SHIPPING_PAYLOAD_ERROR_CODE_TO_T_KEY[code] : null; + + if (key) return safeT(key, code ?? 'SHIPPING_INVALID'); + return safeT('delivery.validation.invalid', code ?? 'SHIPPING_INVALID'); + }; useEffect(() => { if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) { @@ -137,6 +257,299 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } }, [canUseMonobank, canUseStripe, selectedProvider]); + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); + + async function loadShippingMethods() { + setShippingMethodsLoading(true); + setDeliveryUiError(null); + + try { + const qs = new URLSearchParams({ + locale, + currency: cart.summary.currency, + ...(country ? { country } : {}), + }); + + const response = await fetch(`/api/shop/shipping/methods?${qs}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + }); + + const data = await response.json().catch(() => null); + + if (!response.ok) { + if (!cancelled) { + setShippingAvailable(false); + setShippingReasonCode('INTERNAL_ERROR'); + setShippingMethods([]); + } + return; + } + + const VALID_REASON_CODES = new Set([ + 'OK', + 'SHOP_SHIPPING_DISABLED', + 'NP_DISABLED', + 'COUNTRY_NOT_SUPPORTED', + 'CURRENCY_NOT_SUPPORTED', + 'INTERNAL_ERROR', + ]); + + const hardBlock = () => { + setShippingAvailable(false); + setShippingReasonCode('INTERNAL_ERROR'); + setShippingMethods([]); + setSelectedShippingMethod(null); + }; + + if (cancelled) return; + + if (!data || typeof data !== 'object' || Array.isArray(data)) { + hardBlock(); + return; + } + + const obj = data as Record; + + if (typeof obj.available !== 'boolean') { + hardBlock(); + return; + } + + const available = obj.available; + + const reasonRaw = obj.reasonCode; + const reasonCode = + typeof reasonRaw === 'string' && VALID_REASON_CODES.has(reasonRaw) + ? (reasonRaw as ShippingAvailabilityReasonCode) + : null; + + const methodsRaw = obj.methods; + if (!Array.isArray(methodsRaw)) { + hardBlock(); + return; + } + + const methods: ShippingMethod[] = []; + for (const item of methodsRaw) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + hardBlock(); + return; + } + const m = item as Record; + + const providerOk = m.provider === 'nova_poshta'; + const methodCodeOk = + typeof m.methodCode === 'string' && m.methodCode.trim().length > 0; + const titleOk = + typeof m.title === 'string' && m.title.trim().length > 0; + + if (!providerOk || !methodCodeOk || !titleOk) { + hardBlock(); + return; + } + + methods.push({ + provider: 'nova_poshta', + methodCode: m.methodCode as CheckoutDeliveryMethodCode, + title: String(m.title), + }); + } + + if (available === false && reasonCode == null) { + hardBlock(); + return; + } + + setShippingAvailable(available); + setShippingReasonCode(reasonCode); + setShippingMethods(methods); + + if (!available || methods.length === 0) { + setSelectedShippingMethod(null); + return; + } + + setSelectedShippingMethod(current => { + if ( + current && + methods.some(method => method.methodCode === current) + ) { + return current; + } + return methods[0]!.methodCode; + }); + } catch { + if (!cancelled) { + setShippingAvailable(false); + setShippingReasonCode('INTERNAL_ERROR'); + setShippingMethods([]); + } + } finally { + if (!cancelled) { + setShippingMethodsLoading(false); + } + } + } + + void loadShippingMethods(); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [cart.summary.currency, country, locale]); + + useEffect(() => { + setSelectedWarehouseRef(null); + setSelectedWarehouseName(null); + setWarehouseOptions([]); + setWarehouseQuery(''); + }, [selectedCityRef, selectedShippingMethod]); + + useEffect(() => { + if (!shippingAvailable) { + setCityOptions([]); + setCitiesLoading(false); + return; + } + + const query = cityQuery.trim(); + if (query.length < 2) { + setCityOptions([]); + setCitiesLoading(false); + return; + } + + let cancelled = false; + const controller = new AbortController(); + const timeoutId = setTimeout(async () => { + setCitiesLoading(true); + try { + const qs = new URLSearchParams({ + q: query, + locale, + currency: cart.summary.currency, + ...(country ? { country } : {}), + }); + + const response = await fetch(`/api/shop/shipping/np/cities?${qs}`, { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + }); + + const data = await response.json().catch(() => null); + + if (!response.ok || !data || data.available === false) { + if (!cancelled) { + setCityOptions([]); + } + return; + } + + if (!cancelled) { + const next = Array.isArray(data.items) + ? (data.items as ShippingCity[]) + : []; + setCityOptions(next); + } + } catch { + if (!cancelled) { + setCityOptions([]); + } + } finally { + if (!cancelled) { + setCitiesLoading(false); + } + } + }, 250); + + return () => { + cancelled = true; + controller.abort(); + clearTimeout(timeoutId); + }; + }, [cart.summary.currency, cityQuery, country, locale, shippingAvailable]); + + useEffect(() => { + if (!shippingAvailable || !selectedCityRef || !isWarehouseSelectionMethod) { + setWarehouseOptions([]); + setWarehousesLoading(false); + return; + } + + let cancelled = false; + const controller = new AbortController(); + const timeoutId = setTimeout(async () => { + setWarehousesLoading(true); + try { + const qs = new URLSearchParams({ + cityRef: selectedCityRef, + locale, + currency: cart.summary.currency, + ...(country ? { country } : {}), + ...(warehouseQuery.trim().length > 0 + ? { q: warehouseQuery.trim() } + : {}), + }); + + const response = await fetch( + `/api/shop/shipping/np/warehouses?${qs.toString()}`, + { + method: 'GET', + headers: { Accept: 'application/json' }, + cache: 'no-store', + signal: controller.signal, + } + ); + + const data = await response.json().catch(() => null); + + if (!response.ok || !data || data.available === false) { + if (!cancelled) { + setWarehouseOptions([]); + } + return; + } + + if (!cancelled) { + const next = Array.isArray(data.items) + ? (data.items as ShippingWarehouse[]) + : []; + setWarehouseOptions(next); + } + } catch { + if (!cancelled) { + setWarehouseOptions([]); + } + } finally { + if (!cancelled) { + setWarehousesLoading(false); + } + } + }, 250); + + return () => { + cancelled = true; + controller.abort(); + clearTimeout(timeoutId); + }; + }, [ + cart.summary.currency, + country, + isWarehouseSelectionMethod, + locale, + selectedCityRef, + shippingAvailable, + warehouseQuery, + ]); + useEffect(() => { let cancelled = false; const controller = new AbortController(); @@ -315,12 +728,49 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { ); return; } + if (shippingMethodsLoading) { + setCheckoutError(safeT('delivery.methodsLoading', 'METHODS_LOADING')); + return; + } + + if (shippingUnavailableHardBlock) { + setCheckoutError( + resolveShippingUnavailableText(shippingReasonCode) ?? + safeT('delivery.unavailableFallback', 'SHIPPING_UNAVAILABLE') + ); + return; + } setCheckoutError(null); + setDeliveryUiError(null); setCreatedOrderId(null); setIsCheckingOut(true); try { + const shippingPayloadResult = shippingAvailable + ? buildCheckoutShippingPayload({ + shippingAvailable, + reasonCode: shippingReasonCode, + locale, + methodCode: selectedShippingMethod, + cityRef: selectedCityRef, + warehouseRef: selectedWarehouseRef, + addressLine1: courierAddressLine1, + addressLine2: courierAddressLine2, + recipientFullName: recipientName, + recipientPhone: recipientPhone, + recipientEmail, + recipientComment, + }) + : null; + + if (shippingPayloadResult && !shippingPayloadResult.ok) { + const msg = resolveShippingPayloadErrorText(shippingPayloadResult); + setDeliveryUiError(msg); + setCheckoutError(msg); + return; + } + const idempotencyKey = generateIdempotencyKey(); const response = await fetch('/api/shop/checkout', { @@ -331,6 +781,14 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { }, body: JSON.stringify({ paymentProvider: selectedProvider, + ...(shippingPayloadResult?.ok + ? { + shipping: shippingPayloadResult.shipping, + ...(shippingPayloadResult.country + ? { country: shippingPayloadResult.country } + : {}), + } + : {}), items: cart.items.map(item => ({ productId: item.productId, quantity: item.quantity, @@ -406,6 +864,14 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { } } + const shippingUnavailableText = + resolveShippingUnavailableText(shippingReasonCode); + const canPlaceOrder = + hasSelectableProvider && + !shippingMethodsLoading && + !shippingUnavailableHardBlock && + (!shippingAvailable || !!selectedShippingMethod); + const ordersCard = ordersSummary ? (
@@ -675,7 +1141,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) { {t('summary.shipping')} - {t('summary.shippingCalc')} + {t('summary.shippingInformationalOnly')}
@@ -693,8 +1159,289 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
+ +

+ {t('summary.shippingPayOnDeliveryNote')} +

+
+ + {t('delivery.legend')} + + + {shippingMethodsLoading ? ( +

+ {t('delivery.methodsLoading')} +

+ ) : null} + + {!shippingMethodsLoading && !shippingAvailable ? ( +

+ {shippingUnavailableText ?? t('delivery.unavailableFallback')} +

+ ) : null} + + {shippingAvailable ? ( +
+
+ {shippingMethods.map(method => ( + + ))} +
+ +
+ + { + setCityQuery(event.target.value); + setSelectedCityRef(null); + setSelectedCityName(null); + }} + placeholder={t('delivery.city.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> + + {selectedCityRef ? ( +

+ {t('delivery.city.selected', { + city: selectedCityName ?? selectedCityRef, + })} +

+ ) : null} + + {citiesLoading ? ( +

+ {t('delivery.city.searching')} +

+ ) : null} + + {!citiesLoading && cityOptions.length > 0 ? ( +
+ {cityOptions.map(city => ( + + ))} +
+ ) : null} +
+ + {isWarehouseSelectionMethod ? ( +
+ {citiesLoading ? ( +

+ {t('delivery.city.searching')} +

+ ) : null} + + + + { + setWarehouseQuery(event.target.value); + setSelectedWarehouseRef(null); + setSelectedWarehouseName(null); + }} + placeholder={t('delivery.warehouse.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + disabled={!selectedCityRef} + /> + + {selectedWarehouseRef ? ( +

+ {t('delivery.warehouse.selected', { + warehouse: + selectedWarehouseName ?? selectedWarehouseRef, + })} +

+ ) : null} + + {warehousesLoading ? ( +

+ {t('delivery.warehouse.searching')} +

+ ) : null} + + {!warehousesLoading && warehouseOptions.length > 0 ? ( +
+ {warehouseOptions.map(warehouse => ( + + ))} +
+ ) : null} +
+ ) : null} + + {selectedShippingMethod === 'NP_COURIER' ? ( +
+ + + setCourierAddressLine1(event.target.value) + } + placeholder={t( + 'delivery.courierAddress.line1Placeholder' + )} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> + + setCourierAddressLine2(event.target.value) + } + placeholder={t( + 'delivery.courierAddress.line2Placeholder' + )} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ ) : null} + +
+ + setRecipientName(event.target.value)} + placeholder={t('delivery.recipientName.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ +
+ + setRecipientPhone(event.target.value)} + placeholder={t('delivery.recipientPhone.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ +
+ + setRecipientEmail(event.target.value)} + placeholder={t('delivery.recipientEmail.placeholder')} + className="border-border bg-background w-full rounded-md border px-3 py-2 text-sm" + /> +
+ +
+ +