Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ec0827e
(SP: 3) [Backend] add internal janitor (jobs 1-4), claim/lease + runb…
liudmylasovetovs Feb 13, 2026
be244cb
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 13, 2026
3cadf6d
(SP: 3) [Backend] add provider selector, fix payments gating, i18n ch…
liudmylasovetovs Feb 13, 2026
88f52d3
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 13, 2026
ea3a437
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 13, 2026
bfadfe7
Add shop category images to public
liudmylasovetovs Feb 14, 2026
9f93a52
(SP: 3) [Shop][Monobank] I1 structured logging: codes + logging safet…
liudmylasovetovs Feb 14, 2026
1d1852b
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 14, 2026
2ff41a8
(SP: 3) [Shop][Monobank] Fail-closed non-browser origin posture for w…
liudmylasovetovs Feb 14, 2026
a89fc6a
(SP: 3) [Shop][Monobank] [Shop][Monobank] J gate: add orders status o…
liudmylasovetovs Feb 14, 2026
dd1a02f
(SP: 3) [Shop][Monobank] review fixes (tests, logging, success UI)
liudmylasovetovs Feb 15, 2026
155b172
(SP: 1) [Shop][Monobank] Tighten webhook log-code typing; harden DB t…
liudmylasovetovs Feb 15, 2026
1bc435a
(SP: 1) [Shop][Monobank] harden Monobank webhook (origin/PII-safe log…
liudmylasovetovs Feb 15, 2026
0a7b943
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 15, 2026
eb42103
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 16, 2026
cb08926
Merge branch 'develop' of https://github.com/DevLoversTeam/devlovers.…
liudmylasovetovs Feb 17, 2026
4e694a4
(SP: 1) [Cart] adding route for user orders to cart page
liudmylasovetovs Feb 17, 2026
64b482e
(SP: 1) [Cart] fix after review cart mpage and adding index for orders
liudmylasovetovs Feb 17, 2026
3094c75
(SP: 1) [Cart] Fix cart orders summary auth rendering and return tota…
liudmylasovetovs Feb 17, 2026
d11d3dd
(SP: 1) [Cart] remove console.warn from CartPageClient to satisfy mon…
liudmylasovetovs Feb 17, 2026
349929c
(SP: 1) [Cart] rehydrate per cartOwnerId (remove didHydrate coupling)
liudmylasovetovs Feb 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 135 additions & 2 deletions frontend/app/[locale]/shop/cart/CartPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';

import { Loader } from '@/components/shared/Loader';
import { useCart } from '@/components/shop/CartProvider';
import { Link, useRouter } from '@/i18n/routing';
import { formatMoney } from '@/lib/shop/currency';
Expand Down Expand Up @@ -75,14 +76,27 @@ function resolveInitialProvider(args: {
return 'stripe';
}

type OrdersSummaryState = {
count: number;
latestOrderId: string | null;
};

export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
const { cart, updateQuantity, removeFromCart } = useCart();
const router = useRouter();
const t = useTranslations('shop.cart');
const tOrders = useTranslations('shop.orders');
const tColors = useTranslations('shop.catalog.colors');

const [isCheckingOut, setIsCheckingOut] = useState(false);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [createdOrderId, setCreatedOrderId] = useState<string | null>(null);

const [ordersSummary, setOrdersSummary] = useState<OrdersSummaryState | null>(
null
);
const [isOrdersLoading, setIsOrdersLoading] = useState(false);

const [selectedProvider, setSelectedProvider] = useState<CheckoutProvider>(
() =>
resolveInitialProvider({
Expand All @@ -91,6 +105,12 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
currency: cart?.summary?.currency,
})
);
const [isClientReady, setIsClientReady] = useState(false);
const [hasLoadedOrdersSummary, setHasLoadedOrdersSummary] = useState(false);

useEffect(() => {
setIsClientReady(true);
}, []);

const params = useParams<{ locale?: string }>();
const locale = params.locale ?? 'en';
Expand All @@ -110,6 +130,58 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
}
}, [canUseMonobank, canUseStripe, selectedProvider]);

useEffect(() => {
let cancelled = false;

async function loadOrdersSummary() {
setIsOrdersLoading(true);

const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 2500);

try {
const res = await fetch('/api/shop/orders', {
method: 'GET',
headers: { Accept: 'application/json' },
cache: 'no-store',
signal: controller.signal,
});

// cart page can be public; silently ignore if user is not authenticated
if (res.status === 401 || res.status === 403) return;

const data: unknown = await res.json().catch(() => null);
if (!res.ok || !data || typeof data !== 'object') return;

const maybe = data as { success?: unknown; orders?: unknown };
if (maybe.success !== true || !Array.isArray(maybe.orders)) return;

const orders = maybe.orders as Array<{ id?: unknown }>;
const count = orders.length;
const latestOrderId =
typeof orders[0]?.id === 'string' ? orders[0].id : null;

if (!cancelled) {
setOrdersSummary({ count, latestOrderId });
}
} catch {
// ignore (timeout/network) — we just don't show summary
} finally {
clearTimeout(timeoutId);
if (!cancelled) {
setIsOrdersLoading(false);
setHasLoadedOrdersSummary(true);
}
}
}

void loadOrdersSummary();

return () => {
cancelled = true;
};
}, []);

const translateColor = (color: string | null | undefined): string | null => {
if (!color) return null;
const colorSlug = color.toLowerCase();
Expand Down Expand Up @@ -228,6 +300,66 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
}
}

const ORDERS_LINK = cn(SHOP_LINK_BASE, SHOP_LINK_MD, SHOP_FOCUS);

const ORDERS_COUNT_BADGE = cn(
'border-border bg-muted/40 text-foreground inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-semibold tabular-nums'
);

const ORDERS_CARD = cn('border-border rounded-md border p-4');

const ordersCard = ordersSummary ? (
<div className={ORDERS_CARD}>
<div className="flex items-center justify-between gap-3">
<Link href="/shop/orders" className={ORDERS_LINK}>
{tOrders('title')}
</Link>

{isOrdersLoading ? (
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin"
aria-hidden="true"
/>
) : (
<span className={ORDERS_COUNT_BADGE} aria-live="polite">
{ordersSummary.count}
</span>
)}
</div>

<p className="text-muted-foreground mt-2 text-xs">
{tOrders('subtitle')}
</p>

{ordersSummary.latestOrderId ? (
<div className="mt-2">
<Link
href={`/shop/orders/${encodeURIComponent(ordersSummary.latestOrderId)}`}
className={cn(SHOP_LINK_BASE, SHOP_LINK_XS, SHOP_FOCUS)}
>
{t('checkout.goToOrder')}
</Link>
</div>
) : null}
</div>
) : null;
if (!isClientReady || !hasLoadedOrdersSummary) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Decouple cart render from orders-summary fetch

CartPageClient blocks all cart content behind the loader until hasLoadedOrdersSummary becomes true, but that flag is only flipped after the /api/shop/orders request settles (including timeout/abort in the effect above). In slow/error cases, this adds up to a 2.5s delay to viewing cart items and starting checkout for a non-essential summary call, which regresses the primary cart flow; render the cart independently and load the orders card asynchronously.

Useful? React with 👍 / 👎.

return (
<main className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
<div className="flex flex-col items-center justify-center gap-4">
<Loader size={160} className="opacity-90" />
<p
className="text-muted-foreground text-sm"
role="status"
aria-live="polite"
>
Loading…
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</p>
</div>
</main>
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

if (cart.items.length === 0) {
return (
<main className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
Expand Down Expand Up @@ -262,6 +394,8 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
<span className={SHOP_CTA_INSET} aria-hidden="true" />
<span className="relative z-10">{t('startShopping')}</span>
</Link>

{ordersCard ? <div className="mt-6">{ordersCard}</div> : null}
</div>
</div>
</main>
Expand Down Expand Up @@ -406,6 +540,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
</li>
))}
</ul>
{ordersCard ? <div className="mt-6">{ordersCard}</div> : null}
</section>

<aside
Expand Down Expand Up @@ -550,12 +685,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
/>
) : null}

{/* visible label stays stable to avoid wrapping/layout shift */}
<span className="truncate whitespace-nowrap">
{t('checkout.placeOrder')}
</span>

{/* screen readers can still get the “placing” state */}
{isCheckingOut ? (
<span className="sr-only">{t('checkout.placing')}</span>
) : null}
Expand Down
126 changes: 126 additions & 0 deletions frontend/app/api/shop/orders/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import 'server-only';

import crypto from 'node:crypto';

import { desc, eq, sql } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';

import { db } from '@/db';
import { orderItems, orders } from '@/db/schema';
import { getCurrentUser } from '@/lib/auth';
import { logError, logWarn } from '@/lib/logging';

export const dynamic = 'force-dynamic';

function noStoreJson(body: unknown, init?: { status?: number }) {
const res = NextResponse.json(body, { status: init?.status ?? 200 });
res.headers.set('Cache-Control', 'no-store');
return res;
}

type PaymentStatus = (typeof orders.$inferSelect)['paymentStatus'];
type OrderCurrency = (typeof orders.$inferSelect)['currency'];

function toCount(v: unknown): number {
let n = 0;

if (typeof v === 'number') n = v;
else if (typeof v === 'bigint') n = Number(v);
else if (typeof v === 'string') n = Number(v);

if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.trunc(n));
}

export async function GET(request: NextRequest) {
const startedAtMs = Date.now();
const requestId =
request.headers.get('x-request-id')?.trim() || crypto.randomUUID();

const baseMeta = {
requestId,
route: request.nextUrl.pathname,
method: request.method,
};

try {
const user = await getCurrentUser();
if (!user) {
logWarn('public_orders_list_unauthorized', {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid warning on expected anonymous orders requests

This route logs public_orders_list_unauthorized as a warning for every unauthenticated request, but the cart page now calls /api/shop/orders on mount and explicitly treats 401/403 as normal public behavior. That means routine anonymous cart traffic will emit warning-level logs, creating alert noise and obscuring real failures; this path should be downgraded (or skipped) for expected anonymous access.

Useful? React with 👍 / 👎.

...baseMeta,
code: 'UNAUTHORIZED',
durationMs: Date.now() - startedAtMs,
});

return noStoreJson(
{ code: 'UNAUTHORIZED', error: 'Authentication required' },
{ status: 401 }
);
}

const rows = await db
.select({
id: orders.id,
totalAmount: orders.totalAmount,
currency: orders.currency,
paymentStatus: orders.paymentStatus,
createdAt: orders.createdAt,

primaryItemLabel: sql<string | null>`
(
array_agg(
coalesce(
nullif(trim(${orderItems.productTitle}), ''),
nullif(trim(${orderItems.productSlug}), ''),
nullif(trim(${orderItems.productSku}), '')
)
order by ${orderItems.id}
)
filter (
where coalesce(
nullif(trim(${orderItems.productTitle}), ''),
nullif(trim(${orderItems.productSlug}), ''),
nullif(trim(${orderItems.productSku}), '')
) is not null
)
)[1]
`,
itemCount: sql`count(${orderItems.id})`,
})
.from(orders)
.leftJoin(orderItems, eq(orderItems.orderId, orders.id))
.where(eq(orders.userId, user.id))
.groupBy(
orders.id,
orders.totalAmount,
orders.currency,
orders.paymentStatus,
orders.createdAt
)
.orderBy(desc(orders.createdAt))
.limit(50);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const response = rows.map(r => ({
id: String(r.id),
totalAmount: String(r.totalAmount),
currency: r.currency as OrderCurrency,
paymentStatus: r.paymentStatus as PaymentStatus,
createdAt: r.createdAt.toISOString(),
primaryItemLabel: r.primaryItemLabel ?? null,
itemCount: toCount(r.itemCount),
}));

return noStoreJson({ success: true, orders: response }, { status: 200 });
} catch (error) {
logError('public_orders_list_failed', error, {
...baseMeta,
code: 'PUBLIC_ORDERS_LIST_FAILED',
durationMs: Date.now() - startedAtMs,
});

return noStoreJson(
{ code: 'INTERNAL_ERROR', error: 'internal_error' },
{ status: 500 }
);
}
}