Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
134 changes: 132 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 @@ -54,6 +55,14 @@ const SHOP_HERO_CTA = cn(
'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
);

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');

type Props = {
stripeEnabled: boolean;
monobankEnabled: boolean;
Expand All @@ -75,14 +84,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 +113,11 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
currency: cart?.summary?.currency,
})
);
const [isClientReady, setIsClientReady] = useState(false);

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

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

useEffect(() => {
let cancelled = false;
const controller = new AbortController();

async function loadOrdersSummary() {
setIsOrdersLoading(true);

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,
});

if (res.status === 401 || res.status === 403) {
if (!cancelled) {
setOrdersSummary({ count: 0, latestOrderId: null });
}
return;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
}
}
}

void loadOrdersSummary();

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

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

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) {
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" />
</div>
</main>
);
}

if (cart.items.length === 0) {
return (
Expand Down Expand Up @@ -262,6 +391,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 +537,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 +682,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, logInfo } 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) {
logInfo('public_orders_list_unauthorized', {
...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 }
);
}
}
4 changes: 3 additions & 1 deletion frontend/db/schema/shop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export const orders = pgTable(
sql`${table.paymentProvider} <> 'none' OR ${table.paymentStatus} in ('paid','failed')`
),
index('orders_sweep_claim_expires_idx').on(table.sweepClaimExpiresAt),
index('idx_orders_user_id_created_at').on(table.userId, table.createdAt),
]
);

Expand Down Expand Up @@ -635,4 +636,5 @@ export type DbPaymentAttempt = typeof paymentAttempts.$inferSelect;
export type DbApiRateLimit = typeof apiRateLimits.$inferSelect;
export type DbMonobankEvent = typeof monobankEvents.$inferSelect;
export type DbMonobankRefund = typeof monobankRefunds.$inferSelect;
export type DbMonobankPaymentCancel = typeof monobankPaymentCancels.$inferSelect;
export type DbMonobankPaymentCancel =
typeof monobankPaymentCancels.$inferSelect;
2 changes: 2 additions & 0 deletions frontend/drizzle/0014_curvy_ironclad.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CREATE INDEX IF NOT EXISTS idx_orders_user_id_created_at
ON public.orders (user_id, created_at);
Loading