Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
96a7ebf
feat(mobile): improve dashboard, leaderboard & AI helper UX for touch…
TiZorii Feb 12, 2026
2002f6e
Added touchcancel listener
TiZorii Feb 12, 2026
7f20611
Merge pull request #316 from DevLoversTeam/feat/dashboard-leaderboard…
ViktorSvertoka Feb 12, 2026
c883084
(SP: 2) [Frontend] Quiz results dashboard, review cache fix, UX impro…
LesiaUKR Feb 12, 2026
d221d5c
(SP: 3) [Backend] add internal janitor (jobs 1-4), claim/lease + runb…
liudmylasovetovs Feb 13, 2026
1165c15
(SP: 2) [Frontend] Redesign Home Hero & Add Features Section (#319)
yevheniidatsenko Feb 13, 2026
dcd8c0f
(SP: 2) [Frontend] Quiz UX improvements: violations counter, breadcru…
LesiaUKR Feb 13, 2026
03b3871
Header UX polish, quiz highlight fix, Blog button styling, shop i18n …
TiZorii Feb 13, 2026
bd66f7d
(SP: 1) [Frontend] Q&A: Next.js tab states + faster loader start (#324)
ViktorSvertoka Feb 13, 2026
058b769
(SP: 1) [Frontend] Align quiz result messages with status badges, fix…
LesiaUKR Feb 13, 2026
f56413e
chore(release): v1.0.0
ViktorSvertoka Feb 13, 2026
a014f16
Merge remote-tracking branch 'origin/main' into develop
ViktorSvertoka Feb 14, 2026
b53c189
feat(jpg): add images for shop
ViktorSvertoka Feb 14, 2026
2b028af
(SP: 3) [Shop][Monobank] Janitor map + internal janitor endpoint stub…
liudmylasovetovs Feb 15, 2026
1134589
(SP:2) [Frontend] Fix duplicated Q&A items after content updates (#330)
ViktorSvertoka Feb 15, 2026
13d5f77
(SP: 1) [Frontend] Integrate online users counter popup and fix heade…
YNazymko12 Feb 15, 2026
ddc16bc
Bug/fix qa (#332)
ViktorSvertoka Feb 15, 2026
97f120a
chore(release): v1.0.1
ViktorSvertoka Feb 15, 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
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,3 +579,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- Redis caching for quiz questions and review data
- Environment configuration cleanup and standardization
- Improved build stability and dependency management

## [1.0.1] - 2026-02-15

### Added

- Shop / Payments reliability improvements:
- Monobank janitor map documentation as a source of truth
- Internal Monobank janitor endpoint scaffold with strict auth and rate-limit guards
- Post-redirect payment status UX with secure `/status` polling (no-store)
- Homepage engagement:
- Online users counter popup integrated into Hero section
- Single fetch per visit to reduce Neon usage

### Changed

- Header responsiveness:
- Desktop breakpoint adjusted from 1024px to 1050px
- Reduced glow/shimmer intensity in light theme
- Navigation and layout polish:
- Improved loader positioning to avoid overlap with navigation
- Optimized counter positioning logic

### Fixed

- Q&A data integrity:
- Fixed duplicated questions in API responses
- Added Redis cache versioning (`qa:v2:*`)
- Implemented automatic deduplication and cache rewrite on inconsistent data
- Improved pagination total count accuracy
- Cache stability:
- Added TTL for Q&A cache
- Automatic cache invalidation after content seeding
- Header layout issues after counter integration

### Security

- Hardened Monobank flows:
- Stronger origin checks and structured logging without PII
- Ownership protection for `/orders/[id]/status` (IDOR prevention)
- Pre-production test gate improvements

### Infrastructure

- Improved Redis cache reliability for Q&A
- Extended automated tests for caching and payment flows
143 changes: 137 additions & 6 deletions frontend/app/[locale]/shop/cart/CartPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Loader2, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react';
import Image from 'next/image';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useEffect, useState } from 'react';

import { useCart } from '@/components/shop/CartProvider';
import { Link, useRouter } from '@/i18n/routing';
Expand Down Expand Up @@ -54,18 +54,61 @@ const SHOP_HERO_CTA = cn(
'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
);

export default function CartPage() {
type Props = {
stripeEnabled: boolean;
monobankEnabled: boolean;
};

type CheckoutProvider = 'stripe' | 'monobank';

function resolveInitialProvider(args: {
stripeEnabled: boolean;
monobankEnabled: boolean;
currency: string | null | undefined;
}): CheckoutProvider {
const isUah = args.currency === 'UAH';
const canUseStripe = args.stripeEnabled;
const canUseMonobank = args.monobankEnabled && isUah;

if (canUseStripe) return 'stripe';
if (canUseMonobank) return 'monobank';
return 'stripe';
}

export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
const { cart, updateQuantity, removeFromCart } = useCart();
const router = useRouter();
const t = useTranslations('shop.cart');
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 [selectedProvider, setSelectedProvider] = useState<CheckoutProvider>(
() =>
resolveInitialProvider({
stripeEnabled,
monobankEnabled,
currency: cart?.summary?.currency,
})
);

const params = useParams<{ locale?: string }>();
const locale = params.locale ?? 'en';
const shopBase = '/shop';
const isUahCheckout = cart.summary.currency === 'UAH';
const canUseStripe = stripeEnabled;
const canUseMonobank = monobankEnabled && isUahCheckout;
const hasSelectableProvider = canUseStripe || canUseMonobank;

useEffect(() => {
if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) {
setSelectedProvider('monobank');
return;
}
if (selectedProvider === 'monobank' && !canUseMonobank && canUseStripe) {
setSelectedProvider('stripe');
}
}, [canUseMonobank, canUseStripe, selectedProvider]);

const translateColor = (color: string | null | undefined): string | null => {
if (!color) return null;
Expand All @@ -78,6 +121,23 @@ export default function CartPage() {
};

async function handleCheckout() {
if (!hasSelectableProvider) {
setCheckoutError(t('checkout.paymentMethod.noAvailable'));
return;
}
if (selectedProvider === 'stripe' && !canUseStripe) {
setCheckoutError(t('checkout.paymentMethod.noAvailable'));
return;
}
if (selectedProvider === 'monobank' && !canUseMonobank) {
setCheckoutError(
monobankEnabled
? t('checkout.paymentMethod.monobankUahOnlyHint')
: t('checkout.paymentMethod.monobankUnavailable')
);
return;
}

setCheckoutError(null);
setCreatedOrderId(null);
setIsCheckingOut(true);
Expand All @@ -92,6 +152,7 @@ export default function CartPage() {
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
paymentProvider: selectedProvider,
items: cart.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
Expand All @@ -109,13 +170,13 @@ export default function CartPage() {
? data.message
: typeof data?.error === 'string'
? data.error
: 'Unable to start checkout right now.';
: t('checkout.errors.startFailed');
setCheckoutError(message);
return;
}

if (!data?.orderId) {
setCheckoutError('Unexpected checkout response.');
setCheckoutError(t('checkout.errors.unexpectedResponse'));
return;
}

Expand All @@ -125,6 +186,10 @@ export default function CartPage() {
data.clientSecret.trim().length > 0
? data.clientSecret
: null;
const monobankPageUrl: string | null =
typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0
? data.pageUrl
: null;

const orderId = String(data.orderId);
setCreatedOrderId(orderId);
Expand All @@ -137,6 +202,14 @@ export default function CartPage() {
);
return;
}
if (paymentProvider === 'monobank' && monobankPageUrl) {
window.location.assign(monobankPageUrl);
return;
}
if (paymentProvider === 'monobank' && !monobankPageUrl) {
setCheckoutError(t('checkout.errors.unexpectedResponse'));
return;
}
Comment on lines +205 to +212
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate monobankPageUrl before redirecting.

window.location.assign(monobankPageUrl) on line 206 navigates to a URL returned by the server. If the API were ever compromised or returned a javascript: scheme, this could be an open-redirect / XSS vector. Consider validating that the URL starts with https:// before assigning:

🛡️ Proposed validation
       if (paymentProvider === 'monobank' && monobankPageUrl) {
+        try {
+          const url = new URL(monobankPageUrl);
+          if (url.protocol !== 'https:') {
+            setCheckoutError(t('checkout.errors.unexpectedResponse'));
+            return;
+          }
+        } catch {
+          setCheckoutError(t('checkout.errors.unexpectedResponse'));
+          return;
+        }
         window.location.assign(monobankPageUrl);
         return;
       }
🤖 Prompt for AI Agents
In `@frontend/app/`[locale]/shop/cart/CartPageClient.tsx around lines 205 - 212,
The redirect using monobankPageUrl in CartPageClient.tsx should validate the
returned URL before calling window.location.assign to prevent open-redirect/XSS;
update the payment handling branch (the block checking paymentProvider ===
'monobank') to parse and verify monobankPageUrl with the URL constructor and
ensure its protocol is exactly 'https:' and hostname is non-empty (or matches an
allowlist if desired), and if validation fails call
setCheckoutError(t('checkout.errors.unexpectedResponse')) and do not redirect;
keep the existing branch that handles missing monobankPageUrl but replace the
blind assign in the success case with this validation+fail-safe flow.


const paymentsDisabledFlag =
paymentProvider !== 'stripe' || !clientSecret
Expand All @@ -149,7 +222,7 @@ export default function CartPage() {
)}&clearCart=1${paymentsDisabledFlag}`
);
} catch {
setCheckoutError('Unable to start checkout right now.');
setCheckoutError(t('checkout.errors.startFailed'));
} finally {
setIsCheckingOut(false);
}
Expand Down Expand Up @@ -385,11 +458,69 @@ export default function CartPage() {
</div>
</div>

<fieldset className="border-border mt-6 rounded-md border p-4">
<legend className="text-foreground px-1 text-sm font-semibold">
{t('checkout.paymentMethod.label')}
</legend>

<div className="mt-3 space-y-2">
{canUseStripe ? (
<label className="border-border flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2">
<input
type="radio"
name="payment-provider"
value="stripe"
checked={selectedProvider === 'stripe'}
onChange={() => setSelectedProvider('stripe')}
className="h-4 w-4"
/>
<span className="text-sm font-medium">
{t('checkout.paymentMethod.stripe')}
</span>
</label>
) : null}

<label
className={cn(
'border-border flex items-center gap-2 rounded-md border px-3 py-2',
canUseMonobank ? 'cursor-pointer' : 'opacity-60'
)}
>
<input
type="radio"
name="payment-provider"
value="monobank"
checked={selectedProvider === 'monobank'}
onChange={() => setSelectedProvider('monobank')}
disabled={!canUseMonobank}
className="h-4 w-4"
/>
<span className="text-sm font-medium">
{t('checkout.paymentMethod.monobank')}
</span>
</label>

{!canUseMonobank ? (
<p className="text-muted-foreground text-xs">
{monobankEnabled
? t('checkout.paymentMethod.monobankUahOnlyHint')
: t('checkout.paymentMethod.monobankUnavailable')}
</p>
) : null}

{!hasSelectableProvider ? (
<p className="text-destructive text-xs" role="status">
{t('checkout.paymentMethod.noAvailable')}
</p>
) : null}
</div>
</fieldset>

<div className="mt-6 space-y-3">
<button
type="button"
onClick={handleCheckout}
disabled={isCheckingOut}
disabled={isCheckingOut || !hasSelectableProvider}
className={SHOP_HERO_CTA}
aria-busy={isCheckingOut}
>
Expand Down
33 changes: 32 additions & 1 deletion frontend/app/[locale]/shop/cart/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import type { Metadata } from 'next';

import { isMonobankEnabled } from '@/lib/env/monobank';

import CartPageClient from './CartPageClient';

export const metadata: Metadata = {
title: 'Cart | DevLovers',
description: 'Review items in your cart and proceed to checkout.',
};

function isFlagEnabled(value: string | undefined): boolean {
return (value ?? '').trim() === 'true';
}

function resolveStripeCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
const stripeFlag = (process.env.STRIPE_PAYMENTS_ENABLED ?? '').trim();

return (
paymentsEnabled && (stripeFlag.length > 0 ? stripeFlag === 'true' : true)
);
}

function resolveMonobankCheckoutEnabled(): boolean {
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
if (!paymentsEnabled) return false;

try {
return isMonobankEnabled();
} catch {
return false;
}
}

export default function CartPage() {
return <CartPageClient />;
return (
<CartPageClient
stripeEnabled={resolveStripeCheckoutEnabled()}
monobankEnabled={resolveMonobankCheckoutEnabled()}
/>
);
}
Loading