Conversation
… 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
* 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
…mbs, 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
…product descriptions (#322) * Header UX: reorder languages, swap controls, fix quiz highlight, style Blog button * shop i18n product descriptions
* 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
… 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
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Deploy Preview for develop-devlovers ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📝 WalkthroughWalkthroughThis comprehensive 1.0.0 production release introduces homepage redesign with interactive components, quiz results dashboard integration, Monobank payment janitor system for reconciliation, touch-based interactions, pagination controls, extensive internationalization updates, database schema changes, and removal of legacy seed scripts. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Frontend as Frontend App
participant API as Janitor API
participant DB as Database
participant PSP as Payment Service<br/>(Monobank)
User->>Frontend: Dashboard Load
Frontend->>API: POST /api/shop/internal/monobank/janitor<br/>(job1, dryRun, limit, secret)
API->>API: Validate auth & payload
API->>DB: Acquire job slot<br/>(rate-limiting gate)
alt Rate Limited
API-->>Frontend: 429 Retry-After
else Allowed
API->>DB: Query stale payment attempts<br/>(Job1: active, > grace period)
DB-->>API: Candidate list
loop For each candidate
API->>PSP: getInvoiceStatus(invoiceId)
alt Invoice Paid
API->>DB: Update: order→PAID<br/>attempt→succeeded
else Invoice Expired
API->>DB: Restock inventory<br/>Order→INVENTORY_FAILED<br/>Attempt→failed
end
API->>API: Collect counters<br/>(processed, applied, noop, failed)
end
API->>DB: Release job lease
API-->>Frontend: 200 { processed, applied,<br/>noop, failed, metrics }
Frontend->>Frontend: Display results
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~110 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 16
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
frontend/db/queries/quiz.ts (1)
450-488:⚠️ Potential issue | 🟠 MajorInconsistent
pointsEarnedsource betweengetUserLastAttemptPerQuizandgetAttemptReviewDetails.
getUserLastAttemptPerQuiz(line 465) computespointsEarnedviaCOALESCE(pt_sum.total, 0)from aggregatedpoint_transactions, whilegetAttemptReviewDetails(line 505) readspointsEarneddirectly from thequizAttempts.pointsEarnedcolumn (used at lines 551 and 633). If these values diverge (e.g., orphaned transactions were filtered, or the column wasn't backfilled), the dashboard and review pages will show different point totals for the same quiz.Align both queries to use the same source of truth for
pointsEarned.Also applies to: 490-640
frontend/app/layout.tsx (1)
68-68:⚠️ Potential issue | 🟡 MinorHardcoded
lang="uk"applies to all locales; should be dynamic based on[locale]param.The root layout sets
<html lang="uk">statically, but the app supportsen,uk, andplvia the[locale]dynamic route. In Next.js App Router, only the root layout can render the<html>element, so the locale-aware[locale]/layout.tsxcannot override this attribute. This means all pages report themselves as Ukrainian to search engines and assistive technologies, regardless of the actual locale being viewed.The root layout should receive the locale parameter and set
langdynamically—for example, by using the locale from segment parameters or through a wrapper pattern that threads the locale context to the root layout.
🤖 Fix all issues with AI agents
In `@frontend/components/header/DesktopNav.tsx`:
- Around line 44-55: Add a click handler for the Blog HeaderButton that mirrors
the Shop behavior: create a handleBlogClick function that accepts (e:
React.MouseEvent<HTMLAnchorElement>), calls e.preventDefault(), then calls
startNavigation('/blog'); then pass that handler into the Blog HeaderButton via
the onLinkClick prop on the existing <HeaderButton href="/blog" icon={BookOpen}
showArrow ...> so clicking Blog closes the mobile menu consistently (same
pattern used for the Shop button).
In `@frontend/components/home/FlipCardQA.tsx`:
- Line 347: The aria-label prop currently uses a hardcoded English string ("Go
to question ${index + 1}") which breaks i18n; replace this literal with a
translated string from your i18n system (e.g., use the existing t function or
translation hook used in FlipCardQA.tsx) and pass the question index as a
variable to the translation key (update the aria-label on the element that sets
aria-label={`Go to question ${index + 1}`} to something like t('yourKey', {
number: index + 1 }) so it uses the translation key and preserves
accessibility).
- Line 40: The shouldReduceMotion value from useReducedMotion() is collected but
not used; update FlipCardQA to respect it by gating motion features: when
shouldReduceMotion is true, disable the auto-advance interval (where
start/clearInterval or useEffect sets rotation/autoplay), stop any 3D
tilt/pointer-move handlers (e.g. onPointerMove / Tilt component /
handlePointerMove), and avoid animated flip transitions (bypass
react-spring/useSpring or replace animated class toggles with instant state
changes). Locate the shouldReduceMotion variable in FlipCardQA and add
conditional branches to skip starting timers, skip attaching tilt handlers or
pass a reducedMotion prop to child Tilt/Flip components, and change flip logic
to an immediate state toggle without animation.
In `@frontend/components/home/FloatingCode.tsx`:
- Around line 146-161: The jitter is caused by calling Math.random() directly in
the render for the Framer Motion transition.duration inside the FloatingCode
component's motion.div; compute the duration once per snippet (e.g., in a useRef
or useMemo tied to the snippet identity) and replace the inline Math.random()
expression with that stable value so transition.duration uses the persisted
duration instead of a new random value on every re-render.
In `@frontend/components/home/HomePageScroll.tsx`:
- Line 25: The comparison using secondStep.offsetTop assumes the scroll
container is the element's offsetParent; update the scroll container so it's a
positioned element (e.g., add the "relative" class to the container element used
in HomePageScroll) so offsetTop is measured relative to that container, or
alternatively compute positions using getBoundingClientRect relative to the
container if you prefer not to change layout classes; ensure the variable/logic
around shouldEnableSnap (the line computing container.scrollTop <=
secondStep.offsetTop + 4) uses the corrected measurement after this change.
In `@frontend/components/home/InteractiveConstellation.tsx`:
- Around line 30-125: The icons map is recreated on every render inside
InteractiveConstellation which forces unnecessary allocations and can change
useEffect dependencies; move the const icons: Record<IconType, ...> declaration
out of the InteractiveConstellation component into module scope (top-level) so
it is defined once, then update any references inside InteractiveConstellation
(e.g., where icons is used in the canvas drawing useEffect) to use the top-level
icons and remove it from the effect dependency list if present.
In `@frontend/components/home/InteractiveCTAButton.tsx`:
- Line 18: The isFirstRender state and its setter (isFirstRender,
setIsFirstRender) in InteractiveCTAButton are dead code — remove the useState
declaration and the related useEffect that sets setIsFirstRender(false) so
nothing references them; update imports if useState or useEffect become unused
and remove them accordingly to keep the component clean.
In `@frontend/db/queries/leaderboard.ts`:
- Around line 20-29: Replace the raw sql-based subquery used in leftJoin with a
Drizzle-built subquery: build a db.select from pointTransactions selecting
userId and total (use sql`SUM(${pointTransactions.points})::int` for the
aggregate), add the where clause combining eq(pointTransactions.source, 'quiz')
and the or(isNull(pointTransactions.sourceId),
inSubquery(pointTransactions.sourceId, db.select({id:
quizAttempts.id}).from(quizAttempts))), groupBy(pointTransactions.userId), then
call .as('pt_valid') to alias it (referenced as ptValid) and pass that alias
into leftJoin(ptValid, eq(ptValid.userId, users.id)); finally use
coalesce(ptValid.total, 0) in orderBy as before.
In `@frontend/db/queries/quiz.ts`:
- Around line 55-76: The in-memory Map attemptReviewCache (used by
getAttemptReviewCacheKey, getCachedAttemptReview, and cacheAttemptReview) is
unbounded and can leak memory; fix by replacing it with a bounded LRU/TTL cache
(e.g., use an lru-cache instance configured with a sensible max and ttl) or
delegate these functions to the existing Redis layer in quiz-answers-redis.ts
(call the Redis get/set with a 48h TTL instead of using attemptReviewCache).
Update getCachedAttemptReview and cacheAttemptReview to use the new cache API
(or Redis client) and remove/replace the global attemptReviewCache Map to ensure
eviction.
- Around line 473-478: The subquery casting (metadata->>'quizId')::uuid can
throw if metadata->>'quizId' is NULL or malformed; update the pt_sum subquery
(alias pt_sum, table point_transactions, joined to qa.quiz_id) to guard the cast
by adding a WHERE condition such as AND metadata->>'quizId' IS NOT NULL and
(optionally) a UUID-validating regex like (metadata->>'quizId') ~
'^[0-9a-fA-F0-9-]{36}$' (or a stricter UUID regex) before performing the ::uuid
cast so only non-null, well-formed values are cast and aggregated.
In `@frontend/db/seed-questions.ts`:
- Around line 62-73: The seed script currently creates or queries the question
row (using sortOrder, existingQuestion, and the questions table) before
verifying that q.translations exists; move the check for q.translations (and the
early-warning/continue logic) to before any DB insert/select for the question so
you never create an orphan question without translations, and apply the same
change to the later block that also creates questions (the other insert/reuse
code around the translations handling) so both paths validate q.translations
first.
- Around line 75-85: The current construction that computes questionId uses a
non-null assertion on the first element of the array returned by
db.insert(...).returning(...) which can throw if the DB returns no rows; update
the code around questionId/existingQuestion to capture the result of the insert
into a variable (e.g., insertResult = await
db.insert(questions).values({...}).returning({id: questions.id})), check
insertResult.length (or insertResult[0]) and handle the empty case with a clear
error or fallback before accessing .id, then use insertResult[0].id when
present; reference the questionId variable, existingQuestion, and the
db.insert(...).returning(...) call when making the change.
In `@frontend/lib/quiz/quiz-answers-redis.ts`:
- Around line 289-291: The current getAttemptReviewCacheKey(attemptId: string,
userId: string | undefined, locale: string) builds a key containing the literal
"undefined" when userId is missing; change the function to return null (or
undefined) instead of a string when userId is not provided and update all
callers to check the return value and skip caching/lookup when null. Concretely:
modify getAttemptReviewCacheKey to early-return null if userId is falsy, change
its return type to string | null, and ensure callers that call
getAttemptReviewCacheKey(...) guard the result (if key == null -> bypass cache
logic) so unauthenticated users do not share cached review data.
- Around line 293-325: Remove the dead Redis-backed cache functions
getCachedAttemptReview and cacheAttemptReview (and their direct exports) since
the codebase uses the private Map-based cache versions; delete these two
functions and any now-unused constants that only served them (verify
ATTEMPT_REVIEW_TTL if unused), then run a quick search to confirm there are no
remaining imports or references and ensure the project builds.
In `@frontend/lib/services/orders/monobank-janitor.ts`:
- Around line 752-773: The current flow calls
atomicCancelOrderAndFailCreatingAttempt(...) then restockOrder(...), which can
leave inventory leaked if restockOrder throws because the order is already
CANCELED and won't be re-picked by the existing Job2 candidate query (o.status
not in ('PAID', 'CANCELED')); fix by either (A) making the cancel+restock
atomic: move both operations into one DB transaction/atomic operation so that
atomicCancelOrderAndFailCreatingAttempt and the subsequent restock are committed
together and the cancel is rolled back on restock failure, or (B) implement a
recovery sweep/job in monobank-janitor that queries CANCELED monobank orders
where stockRestored = false (or provider = 'monobank' and stockRestored = false)
and re-attempts restockOrder for them, ensuring idempotency and logging;
reference atomicCancelOrderAndFailCreatingAttempt, restockOrder, the Job2
candidate status filter, and the stockRestored flag when adding the new recovery
job or changing transaction boundaries.
🧹 Nitpick comments (38)
frontend/components/quiz/QuizQuestion.tsx (1)
39-39: Inconsistent indentation onnextButtonRefdeclaration.Line 39 has extra leading whitespace compared to the rest of the component body (e.g., lines 33–35, 41). This looks like an accidental formatting issue.
🔧 Fix indentation
- const nextButtonRef = useRef<HTMLButtonElement>(null); + const nextButtonRef = useRef<HTMLButtonElement>(null);frontend/components/q&a/FloatingExplainButton.tsx (1)
75-78: Consider using a singletranslatefor clarity.The two separate transform functions (
translate+translateY) compose correctly, but could be combined into a singletranslate3dor a singletranslatecall for readability. Very minor.- transform: - placement === 'below' - ? 'translate(-50%, 0) translateY(8px)' - : 'translate(-50%, -100%) translateY(-8px)', + transform: + placement === 'below' + ? 'translate(-50%, 8px)' + : 'translate(-50%, calc(-100% - 8px))',frontend/components/shared/Loader.tsx (1)
49-53: Pre-existing: particle array not cleared on effect re-run.If
sizechanges, the effect re-runs butparticlesRef.currentretains stale particles from the previous cycle. The warmup loop then pushes ~200 new particles on top. Consider resetting the array at the start of the effect:const particles = particlesRef.current; + particles.length = 0; const min = width * 0.5;Not introduced by this PR, so deferring is fine.
frontend/db/seed-questions.ts (1)
112-127: Consider batching translation upserts instead of one DB call per locale.Each translation triggers an individual
INSERT … ON CONFLICT DO UPDATEround-trip. For a seed script this is tolerable, but a single batched upsert with a loop overtranslationswould reduce DB round-trips and speed up seeding noticeably when the dataset grows.♻️ Batch upsert approach
+ import { sql } from 'drizzle-orm'; + const insertedLocales: string[] = []; - for (const translation of translations) { - const [inserted] = await db - .insert(questionTranslations) - .values(translation) - .onConflictDoUpdate({ - target: [questionTranslations.questionId, questionTranslations.locale], - set: { - question: translation.question, - answerBlocks: translation.answerBlocks, - }, - }) - .returning({ locale: questionTranslations.locale }); - if (inserted?.locale) { - insertedLocales.push(inserted.locale); - } - } + const insertedRows = await db + .insert(questionTranslations) + .values(translations) + .onConflictDoUpdate({ + target: [questionTranslations.questionId, questionTranslations.locale], + set: { + question: sql`excluded.question`, + answerBlocks: sql`excluded.answer_blocks`, + }, + }) + .returning({ locale: questionTranslations.locale }); + const insertedLocales = insertedRows.map(r => r.locale);Drizzle-orm 0.45.x fully supports this batch pattern with
excludedreferences.frontend/components/leaderboard/LeaderboardTable.tsx (1)
42-46: Colgroup widths duplicate existing<th>width classes.Both the
<colgroup>(lines 42–46) and the<th>elements (lines 50, 53, 56) declare the same widths (w-[15%],w-auto,w-[25%]). Withtable-fixed, column widths are resolved from<colgroup>first, making the<th>width classes redundant. Consider removing thew-*classes from the<th>elements to keep a single source of truth for column sizing.frontend/db/queries/leaderboard.ts (2)
17-17: RepeatedCOALESCE(pt_valid.total, 0)expression — extract to a variable.The same
COALESCE(pt_valid.total, 0)SQL fragment appears in both theselect(line 17) andorderBy(line 30). Extract it into a shared constant to keep them in sync and reduce duplication.♻️ Proposed refactor
+ const totalPoints = sql<number>`COALESCE(pt_valid.total, 0)`; + const dbUsers = await db .select({ id: users.id, username: users.name, avatar: users.image, - points: sql<number>`COALESCE(pt_valid.total, 0)`, + points: totalPoints, }) .from(users) .leftJoin( ... ) - .orderBy(desc(sql`COALESCE(pt_valid.total, 0)`)) + .orderBy(desc(totalPoints)) .limit(50);Also applies to: 30-30
22-26:IN (SELECT id FROM quiz_attempts)may be slow on large tables — acceptable given caching, but worth noting.The uncorrelated
IN (SELECT id FROM quiz_attempts)scans the entirequiz_attemptstable. For the current 1-hour cache window this is fine, but if this pattern is reused elsewhere without caching, consider rewriting asEXISTS (SELECT 1 FROM quiz_attempts qa WHERE qa.id = pt.source_id)which can short-circuit, or ensure an index onquiz_attempts(id)(likely already the PK).frontend/components/header/MainSwitcher.tsx (1)
9-51: Consider extracting shared segment parsing to reduce repetition.Every
is*Pathhelper repeatspathname.split('/').filter(Boolean). A small shared helper (e.g.,getSegments) or a single function that parses once and returns a record of booleans would reduce duplication and the per-render allocation cost of repeatedsplit/filtercalls.This is a minor DRY nit — no urgency.
frontend/package.json (1)
62-62:stripeis pinned to an exact version without a caret.All other dependencies use
^ranges, butstripeis pinned to20.0.0exactly. If this is intentional (e.g., to avoid breaking changes in the Stripe SDK), a brief comment in the codebase or PR description would help future maintainers understand the rationale.frontend/components/header/MobileMenuContext.tsx (1)
76-76: Hardcoded locale list in regex — inconsistent withMainSwitcher.tsx.The regex
/(en|uk|pl)/hardcodes locale values, whileMainSwitcher.tsx(line 29) dynamically uses the importedlocalesarray from@/i18n/config. If a locale is added or removed, this regex will silently break.Consider building the regex dynamically:
Proposed fix
+import { locales } from '@/i18n/config'; ... - const strippedPathname = pathname.replace(/^\/(en|uk|pl)/, '') || '/'; + const localePattern = new RegExp(`^/(${locales.join('|')})`); + const strippedPathname = pathname.replace(localePattern, '') || '/';frontend/app/globals.css (1)
280-292: Replace custom 3D utilities with Tailwind v4 built-ins to reduce custom CSS.These custom utilities duplicate Tailwind CSS v4's built-in support:
backface-hiddenclass → use the built-inbackface-hiddenutilityperspective-1000class → useperspective-[1000px]with arbitrary valuespreserve-3dclass → use the built-intransform-3dutilityfrontend/components/home/WelcomeHeroBackground.tsx (1)
7-9: Inconsistent Tailwind v4 CSS variable syntax.These lines use the v3-style
bg-[var(--accent-primary)]/20bracket notation, while other components in this PR (e.g.,ProfileCard.tsxat lines 32, 39, 62) use the idiomatic v4 parentheses syntaxbg-(--accent-primary)/20. Consider aligning for consistency.♻️ Suggested diff
- <div className="absolute top-[-10rem] left-[-10rem] h-[30rem] w-[30rem] rounded-full bg-[var(--accent-primary)]/20 blur-3xl" /> - <div className="absolute bottom-[-12rem] left-1/4 h-[22rem] w-[22rem] rounded-full bg-[var(--accent-hover)]/15 blur-3xl" /> - <div className="absolute right-0 bottom-[-10rem] h-[26rem] w-[26rem] rounded-full bg-[var(--accent-primary)]/25 blur-3xl" /> + <div className="absolute top-[-10rem] left-[-10rem] h-[30rem] w-[30rem] rounded-full bg-(--accent-primary)/20 blur-3xl" /> + <div className="absolute bottom-[-12rem] left-1/4 h-[22rem] w-[22rem] rounded-full bg-(--accent-hover)/15 blur-3xl" /> + <div className="absolute right-0 bottom-[-10rem] h-[26rem] w-[26rem] rounded-full bg-(--accent-primary)/25 blur-3xl" />The same applies to the star-dot elements on lines 13–17.
frontend/lib/services/orders/monobank-events-claim.ts (1)
33-38: Consider adding a brief doc comment toreadRows.This helper exists to handle drizzle-orm's varying result shapes (raw array vs.
{ rows: [...] }). A short JSDoc would help future maintainers understand why this normalization is needed, since it's not immediately obvious.frontend/components/home/HomePageScroll.tsx (1)
20-29: Cache the second-step element reference instead of querying the DOM on every scroll event.
querySelectorAll('[data-home-step]')runs on every scroll and resize event. Since the step elements don't change after mount, cache the reference once and reuse it.♻️ Suggested refactor
useEffect(() => { const container = containerRef.current; if (!container) return; + const secondStep = container.querySelectorAll<HTMLElement>('[data-home-step]')[1]; + if (!secondStep) return; + const updateSnapMode = () => { - const steps = container.querySelectorAll<HTMLElement>('[data-home-step]'); - const secondStep = steps[1]; - if (!secondStep) return; - const shouldEnableSnap = container.scrollTop <= secondStep.offsetTop + 4; setSnapEnabled(prev => prev === shouldEnableSnap ? prev : shouldEnableSnap ); };frontend/components/quiz/QuizResult.tsx (1)
170-186: Navigation viawindow.location.hrefcauses full page reloads.Lines 173–174, 181, and 196 all use
window.location.hreffor navigation. For the login/signup redirects this is likely intentional (auth flow may need a full reload), but the quiz-review link on line 196 navigates within the app and could benefit from Next.js client-side routing (e.g.,useRouter().push(...)) for a smoother UX.frontend/components/dashboard/ExplainedTermsCard.tsx (1)
99-108:setTermRefcreates a new inner function per index per render.
setTermRef(index)returns a new closure each render, causing React to call the previous ref withnulland the new one with the element on every re-render. This is functionally correct since the Map handles set/delete, but could cause unnecessary ref churn during re-renders if the list is long.Consider caching per-index callbacks in a map to avoid re-creating them:
♻️ Optional: cache per-index ref callbacks
- const setTermRef = useCallback( - (index: number) => (el: HTMLDivElement | null) => { - if (el) { - termRefs.current.set(index, el); - } else { - termRefs.current.delete(index); - } - }, - [] - ); + const termRefCallbacks = useRef(new Map<number, (el: HTMLDivElement | null) => void>()); + + const setTermRef = useCallback((index: number) => { + let cb = termRefCallbacks.current.get(index); + if (!cb) { + cb = (el: HTMLDivElement | null) => { + if (el) termRefs.current.set(index, el); + else termRefs.current.delete(index); + }; + termRefCallbacks.current.set(index, cb); + } + return cb; + }, []);frontend/lib/services/orders/monobank-webhook.ts (1)
1008-1057: Return value may diverge from persisted state after restock failure.When restock fails,
appliedResultin the DB is overwritten to'applied_with_issue'(line 1033), but the function still returns the originalappliedResult(line 1056). Callers (bothapplyMonoWebhookEventandapplyStoredMonobankEvent) will propagate the original value (e.g.,'applied') while the DB now says'applied_with_issue'.If this is intentional (webhook succeeded, restock is a secondary side-effect), consider adding a brief comment at line 1056. If not, return the updated result:
♻️ Option: return the actual persisted result
- return appliedResult; + return (restockReason && restockOrderId) + ? 'applied_with_issue' as ApplyResult + : appliedResult;Or, if the current behavior is intentional, a clarifying comment would help:
+ // Return the original outcome — restock failure is persisted in the DB + // but does not change the caller-visible result. return appliedResult;frontend/components/home/InteractiveConstellation.tsx (1)
169-298: O(n²) work per frame is acceptable at this scale but cacheDate.now().The nested loops for inter-particle repulsion (lines 210-226) and connection drawing (lines 270-297) run O(n²) per frame. With the density-based particle count (~20-50), this is fine for performance.
Minor:
Date.now()is called per-particle at line 181 inside the loop. Caching it once at the top ofdraw()would give consistent pulse timing across all particles within a single frame:const draw = () => { if (!ctx || !canvas) return; ctx.clearRect(0, 0, canvas.width, canvas.height); + const now = Date.now(); // ... particles.forEach((p, i) => { - const pulse = Math.sin((Date.now() * 0.002) + p.rotation * 5) * 0.5 + 0.5; + const pulse = Math.sin((now * 0.002) + p.rotation * 5) * 0.5 + 0.5;frontend/components/home/InteractiveCTAButton.tsx (2)
86-92: Manual ref merging works but consider a helper for clarity.The inline ref callback manually handles both function and object ref types. This is correct, but directly assigning to
forwardedRef.currentis technically a mutation of a read-only ref in React 19's type system (though it works at runtime).If this pattern appears elsewhere, a small
mergeRefsutility would reduce repetition and handle edge cases (e.g.,nullforwarded ref):function mergeRefs<T>(...refs: (React.Ref<T> | null)[]) { return (node: T | null) => { refs.forEach(ref => { if (typeof ref === 'function') ref(node); else if (ref) (ref as React.MutableRefObject<T | null>).current = node; }); }; }
76-83: ConsiderrequestAnimationFrameinstead ofsetIntervalfor gradient rotation.Using
setInterval(…, 16)approximates 60fps but doesn't sync with the browser's paint cycle.requestAnimationFramewould be more efficient and produce smoother results, especially if the tab is backgrounded (rAF pauses, setInterval doesn't).♻️ Optional rAF replacement
useEffect(() => { if (isHovered) { - const interval = setInterval(() => { - rotate.set((rotate.get() + 2) % 360); - }, 16); - return () => clearInterval(interval); + let frameId: number; + const tick = () => { + rotate.set((rotate.get() + 2) % 360); + frameId = requestAnimationFrame(tick); + }; + frameId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frameId); } }, [isHovered, rotate]);frontend/messages/uk.json (1)
674-696: Product descriptions are hardcoded in the translation file.These are substantial marketing copy blocks embedded directly in the locale JSON. This is fine for now, but if product descriptions are expected to grow or be managed by non-developers, consider moving them to a CMS or a separate data source in the future.
frontend/components/quiz/QuizCard.tsx (2)
47-56: Consider extractinggetStatusBadgeas a shared utility.The status badge logic here (lines 47-54) duplicates the
getStatusfunction inQuizResultRow.tsx(lines 41-44) with the same thresholds (100% → success, ≥70% → warning, else danger). Consider extracting a shared helper to keep these in sync.
84-89: Indentation mismatch in the status badge JSX block.The opening
{statusBadge && (at line 84 is indented at 10 spaces, but the closing)}at line 89 is at 8 spaces. This doesn't affect functionality but breaks visual alignment with the surrounding JSX.🔧 Suggested fix
- {statusBadge && ( - <Badge variant={statusBadge.variant} className="gap-1.5 rounded-full"> - <span className={`h-1.5 w-1.5 rounded-full ${statusBadge.dot}`} /> - {statusBadge.label} - </Badge> - )} + {statusBadge && ( + <Badge variant={statusBadge.variant} className="gap-1.5 rounded-full"> + <span className={`h-1.5 w-1.5 rounded-full ${statusBadge.dot}`} /> + {statusBadge.label} + </Badge> + )}frontend/app/[locale]/dashboard/quiz-review/[attemptId]/page.tsx (1)
129-137: Thecn()call with'iconClassName' in categoryStylemay pass unexpected values.On line 135, the expression
'iconClassName' in categoryStyle && categoryStyle.iconClassNamewill pass astring | undefinedtocn()when the property exists. IficonClassNameisundefined,cn(viaclsx) will ignore it, so this is safe in practice. However, using optional chaining would be cleaner:♻️ Suggested simplification
- className={cn('object-contain', 'iconClassName' in categoryStyle && categoryStyle.iconClassName)} + className={cn('object-contain', categoryStyle.iconClassName)}frontend/components/dashboard/QuizResultRow.tsx (1)
90-96: Consider using a<Link>instead of adivwithrole="link"for better semantics and accessibility.The current approach manually implements keyboard navigation (
onKeyDownwith Enter) and usesrole="link"on adiv. A<Link>from@/i18n/routing(or a wrapping<a>) would provide native link behavior (middle-click to open in new tab, right-click context menu, etc.) for free.This is fine for now and works correctly, but worth considering for a follow-up.
frontend/components/q&a/Pagination.tsx (1)
110-123: Minor mismatch betweendisabledcondition and visual styling for the Previous button.Line 112 disables the button when
currentPage <= 1, but line 116 applies the disabled visual style only whencurrentPage === 1. The Next button (lines 165, 169) consistently usescurrentPage >= effectiveTotalPagesfor both. Align them for consistency:♻️ Suggested fix
- currentPage === 1 + currentPage <= 1frontend/messages/en.json (1)
674-695: Product descriptions are hardcoded in the translation file.These multi-line descriptions are product-catalog content rather than UI chrome. Embedding them in translation JSON works for i18n but makes content updates coupled to code deployments. This is acceptable for a v1.0 release but worth noting for future CMS or database migration.
frontend/app/[locale]/dashboard/page.tsx (1)
49-50: Consider parallelizing independent database queries.
getUserQuizStatsandgetUserLastAttemptPerQuizare independent and could run concurrently to reduce page load time:♻️ Suggested refactor
- const attempts = await getUserQuizStats(session.id); - const lastAttempts = await getUserLastAttemptPerQuiz(session.id, locale); + const [attempts, lastAttempts] = await Promise.all([ + getUserQuizStats(session.id), + getUserLastAttemptPerQuiz(session.id, locale), + ]);frontend/app/[locale]/shop/products/[slug]/page.tsx (2)
137-148: Extract description rendering from the IIFE for readability.The inline IIFE in JSX adds cognitive overhead. A simple variable extraction before the
returnwould be cleaner and is consistent with howbadgeLabelis already handled above.♻️ Suggested refactor
Move this above the
returnstatement (e.g., after line 70):+ const description = + (productDescriptions[slug] as string) || product.description || null;Then in JSX, replace lines 137–148 with:
- {(() => { - const desc = - (productDescriptions[slug] as string) || product.description; - if (!desc) return null; - return ( - <div className="text-muted-foreground mt-6 space-y-2"> - {desc.split('\n').map((line: string, i: number) => ( - <p key={i}>{line}</p> - ))} - </div> - ); - })()} + {description && ( + <div className="text-muted-foreground mt-6 space-y-2"> + {description.split('\n').map((line: string, i: number) => ( + <p key={i}>{line}</p> + ))} + </div> + )}
57-59: Pervasiveas anycasts reduce type safety.Lines 42, 45, 49, and now 59 all use
as any. This is an existing pattern in the file, but the new(messages as any).shop?.productDescriptionsis adding to it. Consider typing the messages properly using next-intl'sMessagestype or at least adding a minimal type narrowing utility to avoid silent breakage if translation keys are restructured.frontend/lib/tests/shop/monobank-events-claim.test.ts (1)
48-65: Stale hardcoded cleanup keys — consider removing.Lines 53–54 and 63–64 clean up rows matching
'test:event_key:1'and'test:raw_sha256:1', but these values are never inserted in this test file. They appear to be leftovers from another test or earlier iteration. Removing them would reduce cleanup scope to only what this suite owns.🧹 Remove unused cleanup predicates
await db.execute(sql` delete from monobank_events where event_key like ${`${EVENT_KEY_PREFIX}:%`} or raw_sha256 like ${`${EVENT_KEY_PREFIX}:%`} - or event_key = 'test:event_key:1' - or raw_sha256 = 'test:raw_sha256:1' `);Apply the same change in both
beforeEachandafterEach.frontend/components/home/FloatingCode.tsx (1)
163-186:displayedCode.split('\n')computed twice per render.Lines 165 and 176 both call
displayedCode.split('\n'). During the typing animation, re-renders are frequent. Extract the split result into a local variable.♻️ Extract split result
<pre className="font-mono text-[11px] leading-relaxed text-gray-600 dark:text-gray-400"> <code> - {displayedCode.split('\n').map((line, i) => ( + {(() => { + const lines = displayedCode.split('\n'); + return lines.map((line, i) => ( <div key={i} className="flex min-h-[1.5em]"> <span className="mr-3 w-3 select-none text-right opacity-30 text-xs">{i + 1}</span> <span style={{ color: i === 0 ? snippet.color : 'inherit', textShadow: i === 0 ? `0 0 10px ${snippet.color}40` : 'none', fontWeight: i === 0 ? 600 : 400, }} > {line} - {i === displayedCode.split('\n').length - 1 && ( + {i === lines.length - 1 && ( <motion.span animate={{ opacity: [1, 0, 1] }} transition={{ duration: 0.8, repeat: Infinity }} className="ml-0.5 inline-block h-3 w-1.5 align-middle bg-current opacity-70" /> )} </span> </div> - ))} + )); + })()} </code> </pre>Or more cleanly, extract
linesas aconstbefore the JSX return.frontend/components/home/FeaturesHeroSection.tsx (1)
36-60: Consider extracting the repeated badge markup into a small helper.The three badge blocks (blue, purple, emerald) share identical DOM structure — only the color tokens, icon, and translation key differ. Extracting a
FeatureBadgecomponent (or an inline map) would reduce ~70 lines of near-duplicate JSX to ~15.♻️ Example extraction
const badges = [ { icon: MessageCircleQuestion, color: 'blue', labelKey: 'featureBadges.smartQA' }, { icon: BrainCircuit, color: 'purple', labelKey: 'featureBadges.adaptiveQuizzes' }, { icon: TrendingUp, color: 'emerald', labelKey: 'featureBadges.performance' }, ] as const; // Then map inside JSX: {badges.map(({ icon: Icon, color, labelKey }) => ( <div key={labelKey} className={`group relative overflow-hidden rounded-full border border-${color}-200/60 ...`}> {/* ... */} <Icon className="h-4 w-4" /> <span className="whitespace-nowrap">{t(labelKey)}</span> </div> ))}Note: Tailwind doesn't support dynamic class construction by default (e.g.,
border-${color}-200/60). You'd need to use a lookup map of full class strings per color, or use@source inline(...)to safelist the dynamic classes.frontend/lib/tests/shop/monobank-janitor-job3.test.ts (2)
39-51:as anyassertions suppress type checking on DB inserts.Lines 51 and 65 use
as anyon the.values(...)argument. While common in test helpers, this means schema changes (e.g., new required columns) won't produce compile-time errors in tests. Consider typing these more strictly if the schema is still evolving, or add a comment explaining the intent.Also applies to: 53-65
298-302: Roundabout Date conversion — consider direct.getTime()call.
new Date(String(processing?.appliedAt)).getTime()is defensive but fragile if the string format isn't ISO 8601. If drizzle returnsDateobjects for timestamp columns,processing?.appliedAt?.getTime()is simpler and safer.♻️ Simplify timestamp comparison
- const processingAt = new Date(String(processing?.appliedAt)).getTime(); - const successAt = new Date(String(success?.appliedAt)).getTime(); + const processingAt = processing!.appliedAt!.getTime(); + const successAt = success!.appliedAt!.getTime(); expect(processingAt).toBeLessThanOrEqual(successAt);The non-null assertions are safe here since lines 297–298 already assert these values are truthy.
frontend/app/api/shop/internal/monobank/janitor/route.ts (2)
304-407: Consider extracting shared response construction for job1–job3.The response-building logic for job1, job2, and job3 is identical — only job4 adds a
reportfield. This could be consolidated into a helper to reduce the ~75 lines of duplication.♻️ Suggested refactor
+function buildJobSuccessPayload( + job: JobName, + result: { processed: number; applied: number; noop: number; failed: number; report?: unknown }, + opts: { dryRun: boolean; limit: number; requestId: string } +) { + return { + success: true, + job, + dryRun: opts.dryRun, + limit: opts.limit, + processed: result.processed, + applied: result.applied, + noop: result.noop, + failed: result.failed, + ...(result.report !== undefined ? { report: result.report } : {}), + requestId: opts.requestId, + }; +}Then each job branch becomes:
const result = await runMonobankJanitorJob1({ dryRun, limit, requestId, runId, baseMeta }); return noStoreJson( buildJobSuccessPayload(job, result, { dryRun, limit, requestId }), requestId, { status: 200 } );
431-443: MONO_WEBHOOK_MODE_NOT_STORE branch silently returns 409 — consider adding a log.Unlike the generic error path which calls
logError, this branch returns a 409 without any log entry. For operational visibility, alogWarnhere would help track how often this condition occurs.frontend/lib/services/orders/monobank-janitor.ts (1)
343-356: Job3 claims events sequentially — O(limit) DB round-trips.
claimJob3Eventsmakes oneclaimNextMonobankEventcall per iteration up tolimit(max 500). For a background janitor this is acceptable, but if throughput becomes a concern, consider batch-claiming similar to the Job1/Job2 CTE approach.
| {SITE_LINKS.filter( | ||
| link => link.href !== '/shop' && link.href !== '/blog' | ||
| ).map(link => ( | ||
| <NavLink key={link.href} href={link.href}> | ||
| {t(link.labelKey)} | ||
| </NavLink> | ||
| ))} | ||
| </div> | ||
|
|
||
| <HeaderButton href="/blog" icon={BookOpen} showArrow> | ||
| {t('blog')} | ||
| </HeaderButton> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
rg -n "startNavigation" -C 3 --type=ts --type=tsx | head -80Repository: DevLoversTeam/devlovers.net
Length of output: 98
🏁 Script executed:
# Check how blog variant/route triggers are handled
rg -n "variant.*blog|blog.*variant" -C 2 --type=ts --type=tsx | head -60Repository: DevLoversTeam/devlovers.net
Length of output: 98
🏁 Script executed:
# Check the useMobileMenu context and startNavigation definition
fd -e tsx -e ts | xargs rg -l "useMobileMenu" | head -5Repository: DevLoversTeam/devlovers.net
Length of output: 285
🏁 Script executed:
rg "startNavigation" -C 3 | head -80Repository: DevLoversTeam/devlovers.net
Length of output: 5371
🏁 Script executed:
cat -n frontend/components/header/DesktopNav.tsxRepository: DevLoversTeam/devlovers.net
Length of output: 2306
🏁 Script executed:
rg "variant.*blog" -C 2 | head -40Repository: DevLoversTeam/devlovers.net
Length of output: 2444
Blog HeaderButton should use startNavigation handler to close mobile menu, consistent with Shop button.
The Blog button at line 53 navigates to /blog via standard href but lacks the onLinkClick handler used by the Shop button. The Shop button calls startNavigation('/shop') to close the mobile menu during navigation, but the Blog button has no equivalent handler. Both buttons serve identical roles in the platform variant—routing to variant-specific layouts—so they should consistently manage mobile menu state.
Add a handleBlogClick handler and apply it to the Blog HeaderButton:
const handleBlogClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
startNavigation('/blog');
};
Then update line 53:
<HeaderButton href="/blog" icon={BookOpen} showArrow onLinkClick={handleBlogClick}>
🤖 Prompt for AI Agents
In `@frontend/components/header/DesktopNav.tsx` around lines 44 - 55, Add a click
handler for the Blog HeaderButton that mirrors the Shop behavior: create a
handleBlogClick function that accepts (e: React.MouseEvent<HTMLAnchorElement>),
calls e.preventDefault(), then calls startNavigation('/blog'); then pass that
handler into the Blog HeaderButton via the onLinkClick prop on the existing
<HeaderButton href="/blog" icon={BookOpen} showArrow ...> so clicking Blog
closes the mobile menu consistently (same pattern used for the Shop button).
| const [isMobile, setIsMobile] = useState(false); | ||
| const [progress, setProgress] = useState(0); | ||
| const [isPaused, setIsPaused] = useState(false); | ||
| const shouldReduceMotion = useReducedMotion(); |
There was a problem hiding this comment.
shouldReduceMotion is fetched but never consumed.
useReducedMotion() is called and the result stored, but no animation logic branches on it. Users with prefers-reduced-motion will still see the full 3D tilt, flip, and auto-advance animations. At minimum, consider disabling auto-advance and tilt when reduced motion is preferred.
🤖 Prompt for AI Agents
In `@frontend/components/home/FlipCardQA.tsx` at line 40, The shouldReduceMotion
value from useReducedMotion() is collected but not used; update FlipCardQA to
respect it by gating motion features: when shouldReduceMotion is true, disable
the auto-advance interval (where start/clearInterval or useEffect sets
rotation/autoplay), stop any 3D tilt/pointer-move handlers (e.g. onPointerMove /
Tilt component / handlePointerMove), and avoid animated flip transitions (bypass
react-spring/useSpring or replace animated class toggles with instant state
changes). Locate the shouldReduceMotion variable in FlipCardQA and add
conditional branches to skip starting timers, skip attaching tilt handlers or
pass a reducedMotion prop to child Tilt/Flip components, and change flip logic
to an immediate state toggle without animation.
| useEffect(() => { | ||
| const duration = 8000; | ||
| const interval = 50; | ||
| let elapsed = 0; | ||
|
|
||
| const timer = setInterval(() => { | ||
| if (isFlippedRef.current || isPausedRef.current) { | ||
| elapsed = 0; | ||
| setProgress(0); | ||
| return; | ||
| } | ||
|
|
||
| elapsed += interval; | ||
| const newProgress = (elapsed / duration) * 100; | ||
|
|
||
| if (newProgress >= 100) { | ||
| setIsFlipped(true); | ||
| elapsed = 0; | ||
|
|
||
| if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current); | ||
| if (resetTimerRef.current) clearTimeout(resetTimerRef.current); | ||
|
|
||
| nextCardTimerRef.current = setTimeout(() => { | ||
| setCurrentIndex((prev) => (prev + 1) % questions.length); | ||
| }, 300); | ||
|
|
||
| resetTimerRef.current = setTimeout(() => { | ||
| setIsFlipped(false); | ||
| setProgress(0); | ||
| }, 600); | ||
| } else { | ||
| setProgress(newProgress); | ||
| } | ||
| }, interval); | ||
|
|
||
| flipTimerRef.current = timer; | ||
|
|
||
| return () => { | ||
| clearInterval(timer); | ||
| if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current); | ||
| if (resetTimerRef.current) clearTimeout(resetTimerRef.current); | ||
| }; | ||
| }, [questions.length]); // Only depend on props/stable values |
There was a problem hiding this comment.
Auto-advance sequence briefly exposes the next question's answer.
The timer at 300ms updates currentIndex while the card is still flipped, then at 600ms unflips it. Between 300–600ms, the answer side shows the new question's answer. Depending on the spring animation timing, this may or may not be perceptible, but the state transition order is logically inverted.
A safer approach would be to unflip first, then advance the index after the unflip animation completes:
♻️ Suggested fix
- nextCardTimerRef.current = setTimeout(() => {
- setCurrentIndex((prev) => (prev + 1) % questions.length);
- }, 300);
-
- resetTimerRef.current = setTimeout(() => {
- setIsFlipped(false);
- setProgress(0);
- }, 600);
+ resetTimerRef.current = setTimeout(() => {
+ setIsFlipped(false);
+ setProgress(0);
+ }, 300);
+
+ nextCardTimerRef.current = setTimeout(() => {
+ setCurrentIndex((prev) => (prev + 1) % questions.length);
+ }, 600);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| useEffect(() => { | |
| const duration = 8000; | |
| const interval = 50; | |
| let elapsed = 0; | |
| const timer = setInterval(() => { | |
| if (isFlippedRef.current || isPausedRef.current) { | |
| elapsed = 0; | |
| setProgress(0); | |
| return; | |
| } | |
| elapsed += interval; | |
| const newProgress = (elapsed / duration) * 100; | |
| if (newProgress >= 100) { | |
| setIsFlipped(true); | |
| elapsed = 0; | |
| if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current); | |
| if (resetTimerRef.current) clearTimeout(resetTimerRef.current); | |
| nextCardTimerRef.current = setTimeout(() => { | |
| setCurrentIndex((prev) => (prev + 1) % questions.length); | |
| }, 300); | |
| resetTimerRef.current = setTimeout(() => { | |
| setIsFlipped(false); | |
| setProgress(0); | |
| }, 600); | |
| } else { | |
| setProgress(newProgress); | |
| } | |
| }, interval); | |
| flipTimerRef.current = timer; | |
| return () => { | |
| clearInterval(timer); | |
| if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current); | |
| if (resetTimerRef.current) clearTimeout(resetTimerRef.current); | |
| }; | |
| }, [questions.length]); // Only depend on props/stable values | |
| useEffect(() => { | |
| const duration = 8000; | |
| const interval = 50; | |
| let elapsed = 0; | |
| const timer = setInterval(() => { | |
| if (isFlippedRef.current || isPausedRef.current) { | |
| elapsed = 0; | |
| setProgress(0); | |
| return; | |
| } | |
| elapsed += interval; | |
| const newProgress = (elapsed / duration) * 100; | |
| if (newProgress >= 100) { | |
| setIsFlipped(true); | |
| elapsed = 0; | |
| if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current); | |
| if (resetTimerRef.current) clearTimeout(resetTimerRef.current); | |
| resetTimerRef.current = setTimeout(() => { | |
| setIsFlipped(false); | |
| setProgress(0); | |
| }, 300); | |
| nextCardTimerRef.current = setTimeout(() => { | |
| setCurrentIndex((prev) => (prev + 1) % questions.length); | |
| }, 600); | |
| } else { | |
| setProgress(newProgress); | |
| } | |
| }, interval); | |
| flipTimerRef.current = timer; | |
| return () => { | |
| clearInterval(timer); | |
| if (nextCardTimerRef.current) clearTimeout(nextCardTimerRef.current); | |
| if (resetTimerRef.current) clearTimeout(resetTimerRef.current); | |
| }; | |
| }, [questions.length]); // Only depend on props/stable values |
| key={index} | ||
| onClick={() => handleNavigate(index)} | ||
| className="relative focus:outline-none focus:ring-2 focus:ring-[var(--accent-primary)] focus:ring-offset-2" | ||
| aria-label={`Go to question ${index + 1}`} |
There was a problem hiding this comment.
Hardcoded English in aria-label — not internationalized.
The string "Go to question ${index + 1}" should use a translation key for consistency with the rest of the component's i18n approach.
🤖 Prompt for AI Agents
In `@frontend/components/home/FlipCardQA.tsx` at line 347, The aria-label prop
currently uses a hardcoded English string ("Go to question ${index + 1}") which
breaks i18n; replace this literal with a translated string from your i18n system
(e.g., use the existing t function or translation hook used in FlipCardQA.tsx)
and pass the question index as a variable to the translation key (update the
aria-label on the element that sets aria-label={`Go to question ${index + 1}`}
to something like t('yourKey', { number: index + 1 }) so it uses the translation
key and preserves accessibility).
| <motion.div | ||
| className="relative overflow-hidden rounded-lg border bg-white/80 p-3 backdrop-blur-md dark:bg-gray-950/60" | ||
| style={{ | ||
| borderColor: `${snippet.color}30`, | ||
| boxShadow: `0 8px 32px -4px ${snippet.color}15`, | ||
| }} | ||
| animate={{ | ||
| y: [0, -12, 0], | ||
| rotate: [snippet.rotate, snippet.rotate + 2, snippet.rotate], | ||
| }} | ||
| transition={{ | ||
| duration: 5 + Math.random() * 3, | ||
| repeat: Infinity, | ||
| ease: 'easeInOut', | ||
| delay: snippet.delay, | ||
| }} |
There was a problem hiding this comment.
Math.random() in render path causes animation jitter on every re-render.
Line 157 calls Math.random() inside the component body, so each re-render (triggered by the typing state updates) produces a new duration value. Framer-motion may restart or adjust the infinite bob animation mid-cycle, causing visible jitter. Move the random duration into a stable ref or memo.
🔧 Stabilize animation duration
Compute the random duration once per snippet, for example via useMemo or useRef:
function CodeBlock({ snippet }: { snippet: CodeSnippet }) {
const [displayedCode, setDisplayedCode] = useState('');
const [isTyping, setIsTyping] = useState(false);
+ const bobDuration = useRef(5 + Math.random() * 3).current;
// Refs for cleanupThen use it on line 157:
transition={{
- duration: 5 + Math.random() * 3,
+ duration: bobDuration,
repeat: Infinity,🤖 Prompt for AI Agents
In `@frontend/components/home/FloatingCode.tsx` around lines 146 - 161, The jitter
is caused by calling Math.random() directly in the render for the Framer Motion
transition.duration inside the FloatingCode component's motion.div; compute the
duration once per snippet (e.g., in a useRef or useMemo tied to the snippet
identity) and replace the inline Math.random() expression with that stable value
so transition.duration uses the persisted duration instead of a new random value
on every re-render.
| const sortOrder = q.order ?? 0; | ||
|
|
||
| const [existingQuestion] = await db | ||
| .select({ id: questions.id }) | ||
| .from(questions) | ||
| .where( | ||
| and( | ||
| eq(questions.categoryId, category.id), | ||
| eq(questions.sortOrder, sortOrder) | ||
| ) | ||
| ) | ||
| .limit(1); |
There was a problem hiding this comment.
A question with no translations is still persisted in the DB.
Lines 62–85 insert (or reuse) the question row before checking whether translations exist (line 104). If q.translations is empty, the script logs a warning and continues, leaving an orphan question with no translations. This is fine if you intend questions to exist independently, but if a question without translations is meaningless, consider checking for translations before inserting the question.
Also applies to: 104-108
🤖 Prompt for AI Agents
In `@frontend/db/seed-questions.ts` around lines 62 - 73, The seed script
currently creates or queries the question row (using sortOrder,
existingQuestion, and the questions table) before verifying that q.translations
exists; move the check for q.translations (and the early-warning/continue logic)
to before any DB insert/select for the question so you never create an orphan
question without translations, and apply the same change to the later block that
also creates questions (the other insert/reuse code around the translations
handling) so both paths validate q.translations first.
| const questionId = | ||
| existingQuestion?.id ?? | ||
| ( | ||
| await db | ||
| .insert(questions) | ||
| .values({ | ||
| categoryId: category.id, | ||
| sortOrder, | ||
| }) | ||
| .returning({ id: questions.id }) | ||
| )[0]!.id; |
There was a problem hiding this comment.
Non-null assertion [0]!.id could crash if returning() yields an empty array.
If the insert unexpectedly returns no rows, this will throw a runtime error with an unhelpful message. A small guard would make debugging easier.
🛡️ Proposed defensive fix
- const questionId =
- existingQuestion?.id ??
- (
- await db
- .insert(questions)
- .values({
- categoryId: category.id,
- sortOrder,
- })
- .returning({ id: questions.id })
- )[0]!.id;
+ let questionId = existingQuestion?.id;
+ if (!questionId) {
+ const [inserted] = await db
+ .insert(questions)
+ .values({
+ categoryId: category.id,
+ sortOrder,
+ })
+ .returning({ id: questions.id });
+ if (!inserted) {
+ console.error(
+ `[seed] Failed to insert question for category ${q.category}, sortOrder ${sortOrder}`
+ );
+ continue;
+ }
+ questionId = inserted.id;
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const questionId = | |
| existingQuestion?.id ?? | |
| ( | |
| await db | |
| .insert(questions) | |
| .values({ | |
| categoryId: category.id, | |
| sortOrder, | |
| }) | |
| .returning({ id: questions.id }) | |
| )[0]!.id; | |
| let questionId = existingQuestion?.id; | |
| if (!questionId) { | |
| const [inserted] = await db | |
| .insert(questions) | |
| .values({ | |
| categoryId: category.id, | |
| sortOrder, | |
| }) | |
| .returning({ id: questions.id }); | |
| if (!inserted) { | |
| console.error( | |
| `[seed] Failed to insert question for category ${q.category}, sortOrder ${sortOrder}` | |
| ); | |
| continue; | |
| } | |
| questionId = inserted.id; | |
| } |
🤖 Prompt for AI Agents
In `@frontend/db/seed-questions.ts` around lines 75 - 85, The current construction
that computes questionId uses a non-null assertion on the first element of the
array returned by db.insert(...).returning(...) which can throw if the DB
returns no rows; update the code around questionId/existingQuestion to capture
the result of the insert into a variable (e.g., insertResult = await
db.insert(questions).values({...}).returning({id: questions.id})), check
insertResult.length (or insertResult[0]) and handle the empty case with a clear
error or fallback before accessing .id, then use insertResult[0].id when
present; reference the questionId variable, existingQuestion, and the
db.insert(...).returning(...) call when making the change.
| function getAttemptReviewCacheKey(attemptId: string, userId: string | undefined, locale: string): string { | ||
| return `quiz:attempt-review:${attemptId}:${userId}:${locale}`; | ||
| } |
There was a problem hiding this comment.
Cache key includes literal "undefined" when userId is absent.
When userId is undefined, template interpolation produces the key quiz:attempt-review:<attemptId>:undefined:<locale>. This means all unauthenticated users share one cache entry per attempt+locale, potentially serving one user's review data to another. Either reject undefined early (return null / skip caching) or require userId to be a non-optional string.
Proposed fix
-function getAttemptReviewCacheKey(attemptId: string, userId: string | undefined, locale: string): string {
- return `quiz:attempt-review:${attemptId}:${userId}:${locale}`;
-}
+function getAttemptReviewCacheKey(attemptId: string, userId: string, locale: string): string {
+ return `quiz:attempt-review:${attemptId}:${userId}:${locale}`;
+}And guard callers:
export async function getCachedAttemptReview(
attemptId: string,
- userId: string | undefined,
+ userId: string | undefined,
locale: string
): Promise<AttemptReview | null> {
const redis = getRedisClient();
- if (!redis) return null;
+ if (!redis || !userId) return null; export async function cacheAttemptReview(
attemptId: string,
- userId: string | undefined,
+ userId: string | undefined,
locale: string,
data: AttemptReview
): Promise<void> {
const redis = getRedisClient();
- if (!redis) return;
+ if (!redis || !userId) return;🤖 Prompt for AI Agents
In `@frontend/lib/quiz/quiz-answers-redis.ts` around lines 289 - 291, The current
getAttemptReviewCacheKey(attemptId: string, userId: string | undefined, locale:
string) builds a key containing the literal "undefined" when userId is missing;
change the function to return null (or undefined) instead of a string when
userId is not provided and update all callers to check the return value and skip
caching/lookup when null. Concretely: modify getAttemptReviewCacheKey to
early-return null if userId is falsy, change its return type to string | null,
and ensure callers that call getAttemptReviewCacheKey(...) guard the result (if
key == null -> bypass cache logic) so unauthenticated users do not share cached
review data.
| export async function getCachedAttemptReview( | ||
| attemptId: string, | ||
| userId: string | undefined, | ||
| locale: string | ||
| ): Promise<AttemptReview | null> { | ||
| const redis = getRedisClient(); | ||
| if (!redis) return null; | ||
|
|
||
| try { | ||
| return await redis.get<AttemptReview>(getAttemptReviewCacheKey(attemptId, userId, locale)); | ||
| } catch (err) { | ||
| console.warn('Redis attempt review cache read failed:', err); | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function cacheAttemptReview( | ||
| attemptId: string, | ||
| userId: string | undefined, | ||
| locale: string, | ||
| data: AttemptReview | ||
| ): Promise<void> { | ||
| const redis = getRedisClient(); | ||
| if (!redis) return; | ||
|
|
||
| try { | ||
| await redis.set(getAttemptReviewCacheKey(attemptId, userId, locale), data, { | ||
| ex: ATTEMPT_REVIEW_TTL, | ||
| }); | ||
| } catch (err) { | ||
| console.warn('Redis attempt review cache write failed:', err); | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check which files import getCachedAttemptReview or cacheAttemptReview and from where
rg -n --type=ts "getCachedAttemptReview|cacheAttemptReview" -g '!node_modules'Repository: DevLoversTeam/devlovers.net
Length of output: 680
🏁 Script executed:
#!/bin/bash
# Look at the structure of quiz.ts to find the in-memory cache functions
rg -n "getCachedAttemptReview|cacheAttemptReview" frontend/db/queries/quiz.ts -A 5 -B 2Repository: DevLoversTeam/devlovers.net
Length of output: 1105
🏁 Script executed:
#!/bin/bash
# Confirm the Redis implementations exist
rg -n "export.*function.*getCachedAttemptReview|export.*function.*cacheAttemptReview" frontend/lib/quiz/quiz-answers-redis.ts -A 10Repository: DevLoversTeam/devlovers.net
Length of output: 792
🏁 Script executed:
#!/bin/bash
# Check if quiz-answers-redis is imported anywhere
rg -n "from.*quiz-answers-redis|import.*quiz-answers-redis" --type=tsRepository: DevLoversTeam/devlovers.net
Length of output: 459
🏁 Script executed:
#!/bin/bash
# Check for any imports or requires related to quiz-answers-redis
rg "quiz-answers-redis" --type=tsRepository: DevLoversTeam/devlovers.net
Length of output: 544
🏁 Script executed:
#!/bin/bash
# Check specific imports from quiz-answers-redis in api route
sed -n '1,20p' frontend/app/api/quiz/verify-answer/route.ts | grep -A 5 "quiz-answers-redis"Repository: DevLoversTeam/devlovers.net
Length of output: 261
🏁 Script executed:
#!/bin/bash
# Check the import in actions/quiz.ts
sed -n '230,250p' frontend/actions/quiz.tsRepository: DevLoversTeam/devlovers.net
Length of output: 719
🏁 Script executed:
#!/bin/bash
# Check the full import statement from the api route
sed -n '1,15p' frontend/app/api/quiz/verify-answer/route.tsRepository: DevLoversTeam/devlovers.net
Length of output: 467
Remove dead cache functions — getCachedAttemptReview and cacheAttemptReview are unexported from quiz-answers-redis.ts and never imported.
These two functions are exported from quiz-answers-redis.ts but have zero usages across the codebase. The actual caching is handled by private functions with identical names in frontend/db/queries/quiz.ts (lines 61, 69), which use an in-memory Map. The Redis-backed versions appear to be unused dead code and should be removed to eliminate the duplication and avoid future confusion about which cache layer is active.
🤖 Prompt for AI Agents
In `@frontend/lib/quiz/quiz-answers-redis.ts` around lines 293 - 325, Remove the
dead Redis-backed cache functions getCachedAttemptReview and cacheAttemptReview
(and their direct exports) since the codebase uses the private Map-based cache
versions; delete these two functions and any now-unused constants that only
served them (verify ATTEMPT_REVIEW_TTL if unused), then run a quick search to
confirm there are no remaining imports or references and ensure the project
builds.
| for (const attempt of claimed) { | ||
| processed += 1; | ||
|
|
||
| try { | ||
| const transitionedOrderId = await atomicCancelOrderAndFailCreatingAttempt({ | ||
| attemptId: attempt.id, | ||
| runId: args.runId, | ||
| ttlSeconds, | ||
| now: new Date(), | ||
| }); | ||
|
|
||
| if (!transitionedOrderId) { | ||
| noop += 1; | ||
| continue; | ||
| } | ||
|
|
||
| await restockOrder(transitionedOrderId, { | ||
| reason: 'canceled', | ||
| workerId: 'monobank', | ||
| }); | ||
|
|
||
| applied += 1; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if there's already a recovery mechanism for canceled orders with unreleased stock
rg -n "stockRestored" --type=ts -C3Repository: DevLoversTeam/devlovers.net
Length of output: 50383
🏁 Script executed:
#!/bin/bash
rg -n "CANCELED.*stock" --type=ts -C3Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
#!/bin/bash
rg -n "restock.*cancel" --type=ts -C3Repository: DevLoversTeam/devlovers.net
Length of output: 1096
🏁 Script executed:
#!/bin/bash
# Also search for the Job2 query to verify the candidate filter
rg -n "status not in.*PAID.*CANCELED" --type=ts -C3Repository: DevLoversTeam/devlovers.net
Length of output: 1981
🏁 Script executed:
#!/bin/bash
# Check the monobank-janitor.ts file structure around the mentioned lines
sed -n '700,800p' frontend/lib/services/orders/monobank-janitor.tsRepository: DevLoversTeam/devlovers.net
Length of output: 2505
🏁 Script executed:
#!/bin/bash
# Check sweeps.ts for CANCELED status handling and recovery logic
sed -n '1,400p' frontend/lib/services/orders/sweeps.ts | grep -A 10 -B 10 "CANCELED"Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
#!/bin/bash
# Search for any job/sweep that targets CANCELED orders with stockRestored=false
rg -n "CANCELED.*stockRestored|stockRestored.*CANCELED" --type=tsRepository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
#!/bin/bash
# Find atomicCancelOrderAndFailCreatingAttempt to see what status it sets
rg -n "atomicCancelOrderAndFailCreatingAttempt" --type=ts -A 30 | head -100Repository: DevLoversTeam/devlovers.net
Length of output: 5319
🏁 Script executed:
#!/bin/bash
# Check if there are any recovery jobs for CANCELED orders specifically
fd -e "\.ts" -path "*/services/orders/*" | xargs grep -l "CANCELED.*stock\|recovery.*cancel" 2>/dev/nullRepository: DevLoversTeam/devlovers.net
Length of output: 241
🏁 Script executed:
#!/bin/bash
# Search for any code that targets CANCELED orders in recovery/cleanup
rg -n "CANCELED" frontend/lib/services/orders/ --type=ts | grep -i "stock\|restock\|recovery" | head -20Repository: DevLoversTeam/devlovers.net
Length of output: 344
🏁 Script executed:
#!/bin/bash
# Look at complete sweeps.ts to find all recovery jobs and their conditions
wc -l frontend/lib/services/orders/sweeps.tsRepository: DevLoversTeam/devlovers.net
Length of output: 113
🏁 Script executed:
#!/bin/bash
# Check if there's a specific recovery path for CANCELED orders
rg -n "'CANCELED'" frontend/lib/services/orders/sweeps.ts -A 5 -B 5Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
#!/bin/bash
# Search all of orders directory for jobs/sweeps that handle stockRestored=false
rg -n "stockRestored.*false|ne.*stockRestored" frontend/lib/services/orders/ --type=ts -B 3 -A 3Repository: DevLoversTeam/devlovers.net
Length of output: 4113
🏁 Script executed:
#!/bin/bash
# Check restock.ts to understand how restockOrder handles failures and what conditions it checks
sed -n '40,160p' frontend/lib/services/orders/restock.tsRepository: DevLoversTeam/devlovers.net
Length of output: 3390
🏁 Script executed:
#!/bin/bash
# Check if any sweep targets CANCELED status specifically
sed -n '50,100p' frontend/lib/services/orders/sweeps.tsRepository: DevLoversTeam/devlovers.net
Length of output: 1515
🏁 Script executed:
#!/bin/bash
# Look for all status conditions in sweeps to verify CANCELED is never targeted
rg -n "status.*in|status.*eq" frontend/lib/services/orders/sweeps.ts | head -30Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
#!/bin/bash
# Get full sweeps.ts to see all sweep functions and what statuses they target
cat frontend/lib/services/orders/sweeps.ts | grep -n "export async function\|inArray(orders.paymentProvider\|inArray(orders.status\|'CANCELED'" | head -30Repository: DevLoversTeam/devlovers.net
Length of output: 265
🏁 Script executed:
#!/bin/bash
# Check if there's any monobank-specific recovery for CANCELED orders
rg -n "monobank.*CANCELED|CANCELED.*monobank" frontend/lib/services/orders/ --type=tsRepository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
#!/bin/bash
# Check if restockOrder is wrapped in a transaction anywhere or if it's always separate
rg -n "atomicCancelOrderAndFailCreatingAttempt\|restockOrder" frontend/lib/services/orders/monobank-janitor.ts -A 2 -B 2Repository: DevLoversTeam/devlovers.net
Length of output: 53
🏁 Script executed:
#!/bin/bash
# Get details on all three restock sweep functions to see their target statuses
sed -n '10,130p' frontend/lib/services/orders/sweeps.tsRepository: DevLoversTeam/devlovers.net
Length of output: 3360
🏁 Script executed:
#!/bin/bash
# Also check function signatures for the other sweeps
sed -n '139,270p' frontend/lib/services/orders/sweeps.tsRepository: DevLoversTeam/devlovers.net
Length of output: 3415
🏁 Script executed:
#!/bin/bash
# Check lines 267 onwards for the no-payment sweep
sed -n '267,380p' frontend/lib/services/orders/sweeps.tsRepository: DevLoversTeam/devlovers.net
Length of output: 2920
🏁 Script executed:
#!/bin/bash
# Search for where restockOrder is called in monobank-janitor with context
rg -n "restockOrder" frontend/lib/services/orders/monobank-janitor.ts -B 5 -A 5Repository: DevLoversTeam/devlovers.net
Length of output: 819
Restock failure after atomic cancel leaves inventory unrecoverable for monobank orders.
If restockOrder throws after atomicCancelOrderAndFailCreatingAttempt completes, the order transitions to CANCELED state and won't match the Job2 candidate query (o.status not in ('PAID', 'CANCELED')) on the next run. Additionally, no recovery sweep targets monobank CANCELED orders with stockRestored = false—the existing sweeps only handle Stripe and no-payment providers. This leaves reserved inventory permanently leaked until manual intervention.
Consider either: (a) wrapping the cancel and restock in a single transaction so the cancel is rolled back if restock fails, or (b) adding a recovery job that finds CANCELED orders (or more broadly, monobank orders) with stockRestored = false.
🤖 Prompt for AI Agents
In `@frontend/lib/services/orders/monobank-janitor.ts` around lines 752 - 773, The
current flow calls atomicCancelOrderAndFailCreatingAttempt(...) then
restockOrder(...), which can leave inventory leaked if restockOrder throws
because the order is already CANCELED and won't be re-picked by the existing
Job2 candidate query (o.status not in ('PAID', 'CANCELED')); fix by either (A)
making the cancel+restock atomic: move both operations into one DB
transaction/atomic operation so that atomicCancelOrderAndFailCreatingAttempt and
the subsequent restock are committed together and the cancel is rolled back on
restock failure, or (B) implement a recovery sweep/job in monobank-janitor that
queries CANCELED monobank orders where stockRestored = false (or provider =
'monobank' and stockRestored = false) and re-attempts restockOrder for them,
ensuring idempotency and logging; reference
atomicCancelOrderAndFailCreatingAttempt, restockOrder, the Job2 candidate status
filter, and the stockRestored flag when adding the new recovery job or changing
transaction boundaries.
v1.0.0 - DevLovers Production Release ❤️
Summary by CodeRabbit
New Features
Improvements
Localization