Skip to content

Commit 38bebb2

Browse files
(SP: 1) [Cart] adding route for user orders to cart page (#337)
* (SP: 3) [Backend] add internal janitor (jobs 1-4), claim/lease + runbook (G0-G6) * (SP: 3) [Backend] add provider selector, fix payments gating, i18n checkout errors * Add shop category images to public * (SP: 3) [Shop][Monobank] I1 structured logging: codes + logging safety checks * (SP: 3) [Shop][Monobank] Fail-closed non-browser origin posture for webhook + janitor (ORIGIN_BLOCKED) * (SP: 3) [Shop][Monobank] [Shop][Monobank] J gate: add orders status ownership test and pass all pre-prod invariants * (SP: 3) [Shop][Monobank] review fixes (tests, logging, success UI) * (SP: 1) [Shop][Monobank] Tighten webhook log-code typing; harden DB tests; minor security/log/UI cleanups * (SP: 1) [Shop][Monobank] harden Monobank webhook (origin/PII-safe logs) and remove duplicate sha256 hashing * (SP: 1) [Cart] adding route for user orders to cart page * (SP: 1) [Cart] fix after review cart mpage and adding index for orders * (SP: 1) [Cart] Fix cart orders summary auth rendering and return totalCount for orders badge * (SP: 1) [Cart] remove console.warn from CartPageClient to satisfy monobank logging safety invariant, namespace localStorage cart by user and reset on auth change * (SP: 1) [Cart] rehydrate per cartOwnerId (remove didHydrate coupling)
1 parent 4e50dff commit 38bebb2

15 files changed

Lines changed: 4798 additions & 665 deletions

File tree

frontend/app/[locale]/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export default async function LocaleLayout({
5050

5151
const isAdmin = user?.role === 'admin';
5252
const showAdminNavLink = Boolean(user) && isAdmin && enableAdmin;
53+
const userId = user?.id ?? null;
5354

5455
return (
5556
<NextIntlClientProvider messages={messages}>
@@ -61,6 +62,7 @@ export default async function LocaleLayout({
6162
>
6263
<AppChrome
6364
userExists={userExists}
65+
userId={userId}
6466
showAdminLink={showAdminNavLink}
6567
blogCategories={blogCategories}
6668
>

frontend/app/[locale]/shop/cart/CartPageClient.tsx

Lines changed: 237 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useParams } from 'next/navigation';
66
import { useTranslations } from 'next-intl';
77
import { useEffect, useState } from 'react';
88

9+
import { Loader } from '@/components/shared/Loader';
910
import { useCart } from '@/components/shop/CartProvider';
1011
import { Link, useRouter } from '@/i18n/routing';
1112
import { formatMoney } from '@/lib/shop/currency';
@@ -54,6 +55,14 @@ const SHOP_HERO_CTA = cn(
5455
'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
5556
);
5657

58+
const ORDERS_LINK = cn(SHOP_LINK_BASE, SHOP_LINK_MD, SHOP_FOCUS);
59+
60+
const ORDERS_COUNT_BADGE = cn(
61+
'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'
62+
);
63+
64+
const ORDERS_CARD = cn('border-border rounded-md border p-4');
65+
5766
type Props = {
5867
stripeEnabled: boolean;
5968
monobankEnabled: boolean;
@@ -75,14 +84,27 @@ function resolveInitialProvider(args: {
7584
return 'stripe';
7685
}
7786

87+
type OrdersSummaryState = {
88+
count: number;
89+
latestOrderId: string | null;
90+
};
91+
7892
export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
7993
const { cart, updateQuantity, removeFromCart } = useCart();
8094
const router = useRouter();
8195
const t = useTranslations('shop.cart');
96+
const tOrders = useTranslations('shop.orders');
8297
const tColors = useTranslations('shop.catalog.colors');
98+
8399
const [isCheckingOut, setIsCheckingOut] = useState(false);
84100
const [checkoutError, setCheckoutError] = useState<string | null>(null);
85101
const [createdOrderId, setCreatedOrderId] = useState<string | null>(null);
102+
103+
const [ordersSummary, setOrdersSummary] = useState<OrdersSummaryState | null>(
104+
null
105+
);
106+
const [isOrdersLoading, setIsOrdersLoading] = useState(false);
107+
86108
const [selectedProvider, setSelectedProvider] = useState<CheckoutProvider>(
87109
() =>
88110
resolveInitialProvider({
@@ -91,6 +113,11 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
91113
currency: cart?.summary?.currency,
92114
})
93115
);
116+
const [isClientReady, setIsClientReady] = useState(false);
117+
118+
useEffect(() => {
119+
setIsClientReady(true);
120+
}, []);
94121

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

140+
useEffect(() => {
141+
let cancelled = false;
142+
const controller = new AbortController();
143+
144+
async function loadOrdersSummary() {
145+
setIsOrdersLoading(true);
146+
147+
const timeoutId = setTimeout(() => controller.abort(), 2500);
148+
149+
try {
150+
const res = await fetch('/api/shop/orders', {
151+
method: 'GET',
152+
headers: { Accept: 'application/json' },
153+
cache: 'no-store',
154+
signal: controller.signal,
155+
});
156+
157+
if (res.status === 401 || res.status === 403) {
158+
if (!cancelled) {
159+
setOrdersSummary(null);
160+
}
161+
return;
162+
}
163+
164+
const devWarn = (message: string, meta: Record<string, unknown>) => {
165+
if (process.env.NODE_ENV === 'production') return;
166+
167+
const g = globalThis as unknown as {
168+
__DEVLOVERS_SHOP_DEBUG_LOGS__?: Array<{
169+
level: 'warn';
170+
message: string;
171+
meta: Record<string, unknown>;
172+
ts: number;
173+
}>;
174+
};
175+
176+
if (!g.__DEVLOVERS_SHOP_DEBUG_LOGS__) {
177+
g.__DEVLOVERS_SHOP_DEBUG_LOGS__ = [];
178+
}
179+
180+
g.__DEVLOVERS_SHOP_DEBUG_LOGS__.push({
181+
level: 'warn',
182+
message,
183+
meta,
184+
ts: Date.now(),
185+
});
186+
};
187+
188+
let rawBody: string | null = null;
189+
let data: unknown = null;
190+
let parseError: unknown = null;
191+
192+
try {
193+
rawBody = await res.text();
194+
if (rawBody && rawBody.trim().length > 0) {
195+
try {
196+
data = JSON.parse(rawBody) as unknown;
197+
} catch (err) {
198+
parseError = err;
199+
data = null;
200+
}
201+
}
202+
} catch (err) {
203+
parseError = err;
204+
data = null;
205+
}
206+
207+
const bodyPreview = rawBody ? rawBody.slice(0, 500) : null;
208+
const parseErrorMessage =
209+
parseError instanceof Error
210+
? parseError.message
211+
: parseError
212+
? String(parseError)
213+
: null;
214+
215+
if (!res.ok) {
216+
devWarn('[shop.cart] orders summary fetch non-OK', {
217+
status: res.status,
218+
statusText: res.statusText,
219+
bodyPreview,
220+
parseError: parseErrorMessage,
221+
});
222+
return;
223+
}
224+
225+
if (!data || typeof data !== 'object') {
226+
devWarn('[shop.cart] orders summary fetch invalid JSON', {
227+
status: res.status,
228+
statusText: res.statusText,
229+
bodyType: data === null ? 'null' : typeof data,
230+
bodyPreview,
231+
parseError: parseErrorMessage,
232+
});
233+
return;
234+
}
235+
236+
const maybe = data as {
237+
success?: unknown;
238+
orders?: unknown;
239+
totalCount?: unknown;
240+
};
241+
242+
if (maybe.success !== true || !Array.isArray(maybe.orders)) {
243+
devWarn('[shop.cart] orders summary fetch unexpected shape', {
244+
status: res.status,
245+
statusText: res.statusText,
246+
bodyPreview,
247+
});
248+
return;
249+
}
250+
251+
const orders = maybe.orders as Array<{ id?: unknown }>;
252+
253+
const totalCountRaw = maybe.totalCount;
254+
const totalCountNum =
255+
typeof totalCountRaw === 'number'
256+
? totalCountRaw
257+
: typeof totalCountRaw === 'string'
258+
? Number(totalCountRaw)
259+
: typeof totalCountRaw === 'bigint'
260+
? Number(totalCountRaw)
261+
: NaN;
262+
263+
const count = Number.isFinite(totalCountNum)
264+
? Math.max(0, Math.trunc(totalCountNum))
265+
: orders.length;
266+
267+
const latestOrderId =
268+
typeof orders[0]?.id === 'string' ? orders[0].id : null;
269+
270+
if (!cancelled) {
271+
setOrdersSummary({ count, latestOrderId });
272+
}
273+
} catch {
274+
// ignore (timeout/network) — we just don't show summary
275+
} finally {
276+
clearTimeout(timeoutId);
277+
if (!cancelled) {
278+
setIsOrdersLoading(false);
279+
}
280+
}
281+
}
282+
283+
void loadOrdersSummary();
284+
285+
return () => {
286+
cancelled = true;
287+
controller.abort();
288+
};
289+
}, []);
290+
113291
const translateColor = (color: string | null | undefined): string | null => {
114292
if (!color) return null;
115293
const colorSlug = color.toLowerCase();
@@ -228,6 +406,62 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
228406
}
229407
}
230408

409+
const ordersCard = ordersSummary ? (
410+
<div className={ORDERS_CARD}>
411+
<div className="flex items-center justify-between gap-3">
412+
<Link href="/shop/orders" className={ORDERS_LINK}>
413+
{tOrders('title')}
414+
</Link>
415+
416+
{isOrdersLoading ? (
417+
<Loader2
418+
className="text-muted-foreground h-4 w-4 animate-spin"
419+
aria-hidden="true"
420+
/>
421+
) : (
422+
<span className={ORDERS_COUNT_BADGE} aria-live="polite">
423+
{ordersSummary.count}
424+
</span>
425+
)}
426+
</div>
427+
428+
<p className="text-muted-foreground mt-2 text-xs">
429+
{tOrders('subtitle')}
430+
</p>
431+
432+
{ordersSummary.latestOrderId ? (
433+
<div className="mt-2">
434+
<Link
435+
href={`/shop/orders/${encodeURIComponent(ordersSummary.latestOrderId)}`}
436+
className={cn(SHOP_LINK_BASE, SHOP_LINK_XS, SHOP_FOCUS)}
437+
>
438+
{t('checkout.goToOrder')}
439+
</Link>
440+
</div>
441+
) : null}
442+
</div>
443+
) : null;
444+
const loadingAnnouncement = (() => {
445+
try {
446+
return t('loading');
447+
} catch {
448+
return 'Loading…';
449+
}
450+
})();
451+
452+
if (!isClientReady) {
453+
return (
454+
<main className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
455+
<div className="flex flex-col items-center justify-center gap-4">
456+
<Loader size={160} className="opacity-90" />
457+
<span className="sr-only" role="status" aria-live="polite">
458+
{loadingAnnouncement}
459+
</span>
460+
</div>
461+
</main>
462+
);
463+
}
464+
231465
if (cart.items.length === 0) {
232466
return (
233467
<main className="mx-auto max-w-7xl px-4 py-20 sm:px-6 lg:px-8">
@@ -262,6 +496,8 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
262496
<span className={SHOP_CTA_INSET} aria-hidden="true" />
263497
<span className="relative z-10">{t('startShopping')}</span>
264498
</Link>
499+
500+
{ordersCard ? <div className="mt-6">{ordersCard}</div> : null}
265501
</div>
266502
</div>
267503
</main>
@@ -406,6 +642,7 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
406642
</li>
407643
))}
408644
</ul>
645+
{ordersCard ? <div className="mt-6">{ordersCard}</div> : null}
409646
</section>
410647

411648
<aside
@@ -550,12 +787,10 @@ export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
550787
/>
551788
) : null}
552789

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

558-
{/* screen readers can still get the “placing” state */}
559794
{isCheckingOut ? (
560795
<span className="sr-only">{t('checkout.placing')}</span>
561796
) : null}

0 commit comments

Comments
 (0)