Skip to content

Commit 419c18b

Browse files
ViktorSvertokaTiZoriiLesiaUKRliudmylasovetovsyevheniidatsenko
authored
Release v1.0.1 (#333)
* feat(mobile): improve dashboard, leaderboard & AI helper UX for touch devices - Add touch drag support for AI helper modal and explained terms reorder - Position explain button below selected word on mobile - Show delete/restore buttons always visible on mobile (no hover) - Add user avatar to dashboard profile card (same as leaderboard) - Fix leaderboard page layout - Fix Tailwind v4 canonical class warnings * Added touchcancel listener * (SP: 2) [Frontend] Quiz results dashboard, review cache fix, UX improvements (#317) * (SP: 3) [Backend] add internal janitor (jobs 1-4), claim/lease + runbook (G0-G6) (#318) * (SP: 2) [Frontend] Redesign Home Hero & Add Features Section (#319) * refactor(home): rename hero sections and add complete i18n support - Rename LegacyHeroSection → WelcomeHeroSection - Rename HeroSection → FeaturesHeroSection - Add welcomeDescription translation key to eliminate duplication - Translate all hardcoded text (headings, badges, CTAs) - Improve Ukrainian/Polish translations for better readability - Remove unused legacy components and images * feat(about): update LinkedIn follower count to reflect current stat (1.5k+) * refactor(home): implement i18n for FlipCardQA & fix memory leaks * fix(home): resolve rotateY conflict & scope keyboard events in FlipCardQA * fix(home): resolve all issues * chore(home): cleanup comments, remove dead code & fix trailing spaces * (SP: 2) [Frontend] Quiz UX improvements: violations counter, breadcrumbs, status badges (#320) * feat(quiz): add guest warning before start and bot protection Guest warning: show login/signup/continue buttons for unauthenticated users on quiz rules screen before starting. Bot protection: multi-attempt verification via Redis - each question can only be verified once per user per attempt. Keys use dynamic TTL matching quiz time limit and are cleared on retake. Additional fixes: - Footer flash on quiz navigation (added loading.tsx, eliminated redirect) - Renamed QaLoader to Loader for reuse across pages - React compiler purity errors (crypto.getRandomValues in handlers) - Start button disabled after retake (isStarting not reset) * refactor(quiz): PR review feedback - Extract shared resolveRequestIdentifier() helper to eliminate duplicated auth/IP resolution logic in route.ts and actions/quiz.ts - Return null instead of 'unknown' when identifier unresolvable, skip verification tracking for unidentifiable users - Cap Redis TTL with MAX_TTL (3600s) to prevent client-supplied timeLimitSeconds from persisting keys indefinitely - Add locale prefix to returnTo paths in guest warning links - Replace nested Button inside Link with styled Link to fix invalid HTML (interactive element nesting) * fix(quiz): fall through to IP when auth cookie is expired/invalid * feat(quiz): add quiz results dashboard and review page - Add quiz history section to dashboard with last attempt per quiz - Add review page showing incorrect questions with explanations - Add collapsible cards with expand/collapse all toggle - Add "Review Mistakes" button on quiz result screen - Add category icons to quiz page and review page headers - Add BookOpen icon to explanation block in QuizQuestion - Update guest message to mention error review benefit - Add i18n translations (en/uk/pl) for all new features * fix(quiz): scroll to next button on answer reveal, scope review cache by userId * fix(quiz): restore type imports and userId cache key after merge conflict * fix: restore type imports, sync @swc/helpers, fix indentation after merge * feat(quiz): add violations counter UI, fix disqualification threshold - Add ViolationsCounter component with color escalation (green/yellow/red) - Sticky top bar keeps counter visible on scroll (mobile/tablet) - Add i18n counter keys for en/uk/pl with ICU plural forms - Fix threshold bug: violations warning now triggers at 4+ (was 3+) to match actual integrity score calculation (100 - violations * 10 < 70) * fix(quiz): fix points mismatch between leaderboard and dashboard Dashboard showed raw pointsEarned from last quiz_attempt, while leaderboard summed improvement deltas from point_transactions. Additionally, orphaned transactions from re-seeded quizzes inflated leaderboard totals (12 rows, 83 ghost points cleaned up in DB). - Dashboard query now joins point_transactions to show actual awarded points per quiz instead of raw attempt score - Leaderboard query filters out orphaned transactions where the source attempt no longer exists in quiz_attempts * OBfix(quiz): fix points mismatch, consistent status badges, mobile UX Dashboard showed raw pointsEarned from last attempt while leaderboard summed improvement deltas from point_transactions. Orphaned transactions from re-seeded quizzes inflated leaderboard totals (cleaned up in DB). - Dashboard query joins point_transactions for actual awarded points - Leaderboard query filters orphaned transactions (source_id not in quiz_attempts) - Quiz cards use 3-level badges (Mastered/Review/Study) matching dashboard - Mobile quiz results show dash for zero points, added chevron indicator * fix(quiz): add breadcrumbs to review page, fix recommendation tautology * Header UX polish, quiz highlight fix, Blog button styling, shop i18n product descriptions (#322) * Header UX: reorder languages, swap controls, fix quiz highlight, style Blog button * shop i18n product descriptions * (SP: 1) [Frontend] Q&A: Next.js tab states + faster loader start (#324) * fix(qa): align Next.js tab states and speed up loader startup * feat(home,qa): improve home snap flow and add configurable Q&A page size * fix(i18n,qa,seed): address review issues for locale handling and pagination state * (SP: 1) [Frontend] Align quiz result messages with status badges, fix locale switch on result page (#325) * feat(quiz): add guest warning before start and bot protection Guest warning: show login/signup/continue buttons for unauthenticated users on quiz rules screen before starting. Bot protection: multi-attempt verification via Redis - each question can only be verified once per user per attempt. Keys use dynamic TTL matching quiz time limit and are cleared on retake. Additional fixes: - Footer flash on quiz navigation (added loading.tsx, eliminated redirect) - Renamed QaLoader to Loader for reuse across pages - React compiler purity errors (crypto.getRandomValues in handlers) - Start button disabled after retake (isStarting not reset) * refactor(quiz): PR review feedback - Extract shared resolveRequestIdentifier() helper to eliminate duplicated auth/IP resolution logic in route.ts and actions/quiz.ts - Return null instead of 'unknown' when identifier unresolvable, skip verification tracking for unidentifiable users - Cap Redis TTL with MAX_TTL (3600s) to prevent client-supplied timeLimitSeconds from persisting keys indefinitely - Add locale prefix to returnTo paths in guest warning links - Replace nested Button inside Link with styled Link to fix invalid HTML (interactive element nesting) * fix(quiz): fall through to IP when auth cookie is expired/invalid * feat(quiz): add quiz results dashboard and review page - Add quiz history section to dashboard with last attempt per quiz - Add review page showing incorrect questions with explanations - Add collapsible cards with expand/collapse all toggle - Add "Review Mistakes" button on quiz result screen - Add category icons to quiz page and review page headers - Add BookOpen icon to explanation block in QuizQuestion - Update guest message to mention error review benefit - Add i18n translations (en/uk/pl) for all new features * fix(quiz): scroll to next button on answer reveal, scope review cache by userId * fix(quiz): restore type imports and userId cache key after merge conflict * fix: restore type imports, sync @swc/helpers, fix indentation after merge * feat(quiz): add violations counter UI, fix disqualification threshold - Add ViolationsCounter component with color escalation (green/yellow/red) - Sticky top bar keeps counter visible on scroll (mobile/tablet) - Add i18n counter keys for en/uk/pl with ICU plural forms - Fix threshold bug: violations warning now triggers at 4+ (was 3+) to match actual integrity score calculation (100 - violations * 10 < 70) * fix(quiz): fix points mismatch between leaderboard and dashboard Dashboard showed raw pointsEarned from last quiz_attempt, while leaderboard summed improvement deltas from point_transactions. Additionally, orphaned transactions from re-seeded quizzes inflated leaderboard totals (12 rows, 83 ghost points cleaned up in DB). - Dashboard query now joins point_transactions to show actual awarded points per quiz instead of raw attempt score - Leaderboard query filters out orphaned transactions where the source attempt no longer exists in quiz_attempts * OBfix(quiz): fix points mismatch, consistent status badges, mobile UX Dashboard showed raw pointsEarned from last attempt while leaderboard summed improvement deltas from point_transactions. Orphaned transactions from re-seeded quizzes inflated leaderboard totals (cleaned up in DB). - Dashboard query joins point_transactions for actual awarded points - Leaderboard query filters orphaned transactions (source_id not in quiz_attempts) - Quiz cards use 3-level badges (Mastered/Review/Study) matching dashboard - Mobile quiz results show dash for zero points, added chevron indicator * fix(quiz): add breadcrumbs to review page, fix recommendation tautology * fix(quiz): align result messages with status badges, persist result on locale switch * chore(release): v1.0.0 * feat(jpg): add images for shop * (SP: 3) [Shop][Monobank] Janitor map + internal janitor endpoint stub + status UX + security/obs + J test gate (#328) * (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:2) [Frontend] Fix duplicated Q&A items after content updates (#330) * fix(qa): prevent duplicate questions and improve cache invalidation * fix(qa): keep pagination totals consistent after deduplication * (SP: 1) [Frontend] Integrate online users counter popup and fix header (#331) * feat(home): add online users counter + fix header breakpoint * deleted scrollY in OnlineCounterPopup * fixed fetch in OnlineCounterPopup * Bug/fix qa (#332) * fix(qa): prevent duplicate questions and improve cache invalidation * fix(qa): keep pagination totals consistent after deduplication * fix(qa): paginate by unique questions and bump cache namespace * chore(release): v1.0.1 --------- Co-authored-by: tetiana zorii <tanyusha.zoriy@gmail.com> Co-authored-by: Lesia Soloviova <106915140+LesiaUKR@users.noreply.github.com> Co-authored-by: liudmylasovetovs <127711697+liudmylasovetovs@users.noreply.github.com> Co-authored-by: Yevhenii Datsenko <134847096+yevheniidatsenko@users.noreply.github.com> Co-authored-by: Tetiana Zorii <131365289+TiZorii@users.noreply.github.com> Co-authored-by: Yuliia Nazymko <122815071+YNazymko12@users.noreply.github.com>
1 parent b0713f3 commit 419c18b

57 files changed

Lines changed: 4701 additions & 281 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,3 +579,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
579579
- Redis caching for quiz questions and review data
580580
- Environment configuration cleanup and standardization
581581
- Improved build stability and dependency management
582+
583+
## [1.0.1] - 2026-02-15
584+
585+
### Added
586+
587+
- Shop / Payments reliability improvements:
588+
- Monobank janitor map documentation as a source of truth
589+
- Internal Monobank janitor endpoint scaffold with strict auth and rate-limit guards
590+
- Post-redirect payment status UX with secure `/status` polling (no-store)
591+
- Homepage engagement:
592+
- Online users counter popup integrated into Hero section
593+
- Single fetch per visit to reduce Neon usage
594+
595+
### Changed
596+
597+
- Header responsiveness:
598+
- Desktop breakpoint adjusted from 1024px to 1050px
599+
- Reduced glow/shimmer intensity in light theme
600+
- Navigation and layout polish:
601+
- Improved loader positioning to avoid overlap with navigation
602+
- Optimized counter positioning logic
603+
604+
### Fixed
605+
606+
- Q&A data integrity:
607+
- Fixed duplicated questions in API responses
608+
- Added Redis cache versioning (`qa:v2:*`)
609+
- Implemented automatic deduplication and cache rewrite on inconsistent data
610+
- Improved pagination total count accuracy
611+
- Cache stability:
612+
- Added TTL for Q&A cache
613+
- Automatic cache invalidation after content seeding
614+
- Header layout issues after counter integration
615+
616+
### Security
617+
618+
- Hardened Monobank flows:
619+
- Stronger origin checks and structured logging without PII
620+
- Ownership protection for `/orders/[id]/status` (IDOR prevention)
621+
- Pre-production test gate improvements
622+
623+
### Infrastructure
624+
625+
- Improved Redis cache reliability for Q&A
626+
- Extended automated tests for caching and payment flows

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

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Loader2, Minus, Plus, ShoppingBag, Trash2 } from 'lucide-react';
44
import Image from 'next/image';
55
import { useParams } from 'next/navigation';
66
import { useTranslations } from 'next-intl';
7-
import { useState } from 'react';
7+
import { useEffect, useState } from 'react';
88

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

57-
export default function CartPage() {
57+
type Props = {
58+
stripeEnabled: boolean;
59+
monobankEnabled: boolean;
60+
};
61+
62+
type CheckoutProvider = 'stripe' | 'monobank';
63+
64+
function resolveInitialProvider(args: {
65+
stripeEnabled: boolean;
66+
monobankEnabled: boolean;
67+
currency: string | null | undefined;
68+
}): CheckoutProvider {
69+
const isUah = args.currency === 'UAH';
70+
const canUseStripe = args.stripeEnabled;
71+
const canUseMonobank = args.monobankEnabled && isUah;
72+
73+
if (canUseStripe) return 'stripe';
74+
if (canUseMonobank) return 'monobank';
75+
return 'stripe';
76+
}
77+
78+
export default function CartPage({ stripeEnabled, monobankEnabled }: Props) {
5879
const { cart, updateQuantity, removeFromCart } = useCart();
5980
const router = useRouter();
6081
const t = useTranslations('shop.cart');
6182
const tColors = useTranslations('shop.catalog.colors');
6283
const [isCheckingOut, setIsCheckingOut] = useState(false);
6384
const [checkoutError, setCheckoutError] = useState<string | null>(null);
6485
const [createdOrderId, setCreatedOrderId] = useState<string | null>(null);
86+
const [selectedProvider, setSelectedProvider] = useState<CheckoutProvider>(
87+
() =>
88+
resolveInitialProvider({
89+
stripeEnabled,
90+
monobankEnabled,
91+
currency: cart?.summary?.currency,
92+
})
93+
);
6594

6695
const params = useParams<{ locale?: string }>();
6796
const locale = params.locale ?? 'en';
6897
const shopBase = '/shop';
98+
const isUahCheckout = cart.summary.currency === 'UAH';
99+
const canUseStripe = stripeEnabled;
100+
const canUseMonobank = monobankEnabled && isUahCheckout;
101+
const hasSelectableProvider = canUseStripe || canUseMonobank;
102+
103+
useEffect(() => {
104+
if (selectedProvider === 'stripe' && !canUseStripe && canUseMonobank) {
105+
setSelectedProvider('monobank');
106+
return;
107+
}
108+
if (selectedProvider === 'monobank' && !canUseMonobank && canUseStripe) {
109+
setSelectedProvider('stripe');
110+
}
111+
}, [canUseMonobank, canUseStripe, selectedProvider]);
69112

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

80123
async function handleCheckout() {
124+
if (!hasSelectableProvider) {
125+
setCheckoutError(t('checkout.paymentMethod.noAvailable'));
126+
return;
127+
}
128+
if (selectedProvider === 'stripe' && !canUseStripe) {
129+
setCheckoutError(t('checkout.paymentMethod.noAvailable'));
130+
return;
131+
}
132+
if (selectedProvider === 'monobank' && !canUseMonobank) {
133+
setCheckoutError(
134+
monobankEnabled
135+
? t('checkout.paymentMethod.monobankUahOnlyHint')
136+
: t('checkout.paymentMethod.monobankUnavailable')
137+
);
138+
return;
139+
}
140+
81141
setCheckoutError(null);
82142
setCreatedOrderId(null);
83143
setIsCheckingOut(true);
@@ -92,6 +152,7 @@ export default function CartPage() {
92152
'Idempotency-Key': idempotencyKey,
93153
},
94154
body: JSON.stringify({
155+
paymentProvider: selectedProvider,
95156
items: cart.items.map(item => ({
96157
productId: item.productId,
97158
quantity: item.quantity,
@@ -109,13 +170,13 @@ export default function CartPage() {
109170
? data.message
110171
: typeof data?.error === 'string'
111172
? data.error
112-
: 'Unable to start checkout right now.';
173+
: t('checkout.errors.startFailed');
113174
setCheckoutError(message);
114175
return;
115176
}
116177

117178
if (!data?.orderId) {
118-
setCheckoutError('Unexpected checkout response.');
179+
setCheckoutError(t('checkout.errors.unexpectedResponse'));
119180
return;
120181
}
121182

@@ -125,6 +186,10 @@ export default function CartPage() {
125186
data.clientSecret.trim().length > 0
126187
? data.clientSecret
127188
: null;
189+
const monobankPageUrl: string | null =
190+
typeof data.pageUrl === 'string' && data.pageUrl.trim().length > 0
191+
? data.pageUrl
192+
: null;
128193

129194
const orderId = String(data.orderId);
130195
setCreatedOrderId(orderId);
@@ -137,6 +202,14 @@ export default function CartPage() {
137202
);
138203
return;
139204
}
205+
if (paymentProvider === 'monobank' && monobankPageUrl) {
206+
window.location.assign(monobankPageUrl);
207+
return;
208+
}
209+
if (paymentProvider === 'monobank' && !monobankPageUrl) {
210+
setCheckoutError(t('checkout.errors.unexpectedResponse'));
211+
return;
212+
}
140213

141214
const paymentsDisabledFlag =
142215
paymentProvider !== 'stripe' || !clientSecret
@@ -149,7 +222,7 @@ export default function CartPage() {
149222
)}&clearCart=1${paymentsDisabledFlag}`
150223
);
151224
} catch {
152-
setCheckoutError('Unable to start checkout right now.');
225+
setCheckoutError(t('checkout.errors.startFailed'));
153226
} finally {
154227
setIsCheckingOut(false);
155228
}
@@ -385,11 +458,69 @@ export default function CartPage() {
385458
</div>
386459
</div>
387460

461+
<fieldset className="border-border mt-6 rounded-md border p-4">
462+
<legend className="text-foreground px-1 text-sm font-semibold">
463+
{t('checkout.paymentMethod.label')}
464+
</legend>
465+
466+
<div className="mt-3 space-y-2">
467+
{canUseStripe ? (
468+
<label className="border-border flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2">
469+
<input
470+
type="radio"
471+
name="payment-provider"
472+
value="stripe"
473+
checked={selectedProvider === 'stripe'}
474+
onChange={() => setSelectedProvider('stripe')}
475+
className="h-4 w-4"
476+
/>
477+
<span className="text-sm font-medium">
478+
{t('checkout.paymentMethod.stripe')}
479+
</span>
480+
</label>
481+
) : null}
482+
483+
<label
484+
className={cn(
485+
'border-border flex items-center gap-2 rounded-md border px-3 py-2',
486+
canUseMonobank ? 'cursor-pointer' : 'opacity-60'
487+
)}
488+
>
489+
<input
490+
type="radio"
491+
name="payment-provider"
492+
value="monobank"
493+
checked={selectedProvider === 'monobank'}
494+
onChange={() => setSelectedProvider('monobank')}
495+
disabled={!canUseMonobank}
496+
className="h-4 w-4"
497+
/>
498+
<span className="text-sm font-medium">
499+
{t('checkout.paymentMethod.monobank')}
500+
</span>
501+
</label>
502+
503+
{!canUseMonobank ? (
504+
<p className="text-muted-foreground text-xs">
505+
{monobankEnabled
506+
? t('checkout.paymentMethod.monobankUahOnlyHint')
507+
: t('checkout.paymentMethod.monobankUnavailable')}
508+
</p>
509+
) : null}
510+
511+
{!hasSelectableProvider ? (
512+
<p className="text-destructive text-xs" role="status">
513+
{t('checkout.paymentMethod.noAvailable')}
514+
</p>
515+
) : null}
516+
</div>
517+
</fieldset>
518+
388519
<div className="mt-6 space-y-3">
389520
<button
390521
type="button"
391522
onClick={handleCheckout}
392-
disabled={isCheckingOut}
523+
disabled={isCheckingOut || !hasSelectableProvider}
393524
className={SHOP_HERO_CTA}
394525
aria-busy={isCheckingOut}
395526
>
Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,43 @@
11
import type { Metadata } from 'next';
22

3+
import { isMonobankEnabled } from '@/lib/env/monobank';
4+
35
import CartPageClient from './CartPageClient';
46

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

12+
function isFlagEnabled(value: string | undefined): boolean {
13+
return (value ?? '').trim() === 'true';
14+
}
15+
16+
function resolveStripeCheckoutEnabled(): boolean {
17+
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
18+
const stripeFlag = (process.env.STRIPE_PAYMENTS_ENABLED ?? '').trim();
19+
20+
return (
21+
paymentsEnabled && (stripeFlag.length > 0 ? stripeFlag === 'true' : true)
22+
);
23+
}
24+
25+
function resolveMonobankCheckoutEnabled(): boolean {
26+
const paymentsEnabled = isFlagEnabled(process.env.PAYMENTS_ENABLED);
27+
if (!paymentsEnabled) return false;
28+
29+
try {
30+
return isMonobankEnabled();
31+
} catch {
32+
return false;
33+
}
34+
}
35+
1036
export default function CartPage() {
11-
return <CartPageClient />;
37+
return (
38+
<CartPageClient
39+
stripeEnabled={resolveStripeCheckoutEnabled()}
40+
monobankEnabled={resolveMonobankCheckoutEnabled()}
41+
/>
42+
);
1243
}

0 commit comments

Comments
 (0)