fix: billing idempotency atomic, logo in navbar, dark bg on app pages, Home btn, resend-verification

- billing: atomic upsert-based idempotency (fixes TOCTOU + crash-between-ops race)
- billing: payment_id uses `or` to handle explicit null payment_intent
- Header: logo h-[44px] contained within navbar frame, remove md overflow
- Header: Home button scrolls to top when already on /
- Header: useMemo limelightItems to prevent useLayoutEffect thrash on scroll
- Header: remove dead scrolled ternary (py-2 : py-2)
- Hero: remove md:pt-[176px] gap (logo no longer overflows)
- LimelightNav: clearTimeout cleanup, remove items from effect deps
- SyntheticUsers/FocusGroups: bg-slate-50 → bg-background (dark theme fix)
- api.ts + Dashboard: resendVerification passes user email (fixes 400 error)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-23 22:55:06 +01:00
parent 28bb04a2b2
commit 4e910704bc
8 changed files with 53 additions and 29 deletions

View file

@ -112,27 +112,40 @@ async def stripe_webhook():
user_id = meta.get("user_id")
credits = int(meta.get("credits", 0))
pack_id = meta.get("pack_id", "")
payment_id = session.get("payment_intent", session.get("id", ""))
# `or` so explicit null payment_intent falls through to session id
payment_id = session.get("payment_intent") or session.get("id", "")
if not user_id or credits <= 0:
if not user_id or credits <= 0 or not payment_id:
return jsonify({"status": "ok"}), 200
# Idempotency — skip if this payment_intent was already processed
from datetime import datetime, timezone
from app.db import get_db as _get_db
db = await _get_db()
already = await db.credit_transactions.find_one({"ref.stripe_payment_id": payment_id})
if already:
# Atomic idempotency: insert the ledger slot first so concurrent Stripe
# retries both see the record — only the request that inserts it proceeds
slot = await db.credit_transactions.update_one(
{"ref.stripe_payment_id": payment_id},
{"$setOnInsert": {
"user_id": user_id,
"type": "purchase",
"amount": credits,
"balance_after": None,
"description": f"Purchased {pack_id} pack ({credits} credits)",
"ref": {"stripe_payment_id": payment_id},
"ts": datetime.now(timezone.utc),
}},
upsert=True
)
if slot.upserted_id is None:
logger.info("Duplicate webhook for payment %s — skipping", payment_id)
return jsonify({"status": "ok"}), 200
# Slot claimed — grant credits and record final balance
balance = await User.grant_credits(user_id, credits)
await CreditTransaction.record(
user_id=user_id,
tx_type="purchase",
amount=credits,
balance_after=balance,
description=f"Purchased {pack_id} pack ({credits} credits)",
ref={"stripe_payment_id": payment_id},
await db.credit_transactions.update_one(
{"_id": slot.upserted_id},
{"$set": {"balance_after": balance}}
)
logger.info("Granted %d credits to user %s via Stripe payment %s", credits, user_id, payment_id)

View file

@ -212,7 +212,7 @@ const Dashboard = () => {
const handleResendEmail = async () => {
setResendingEmail(true);
try {
await authApi.resendVerification();
await authApi.resendVerification((user as any)?.email);
toastService.success('Verification email sent', { description: 'Check your inbox' });
} catch {
toastService.error('Failed to send email');

View file

@ -119,7 +119,7 @@ export default function Hero() {
const shouldAnimate = !shouldReduce;
return (
<section className="relative flex flex-col justify-start overflow-hidden -mt-[80px] pt-[80px] md:pt-[176px]">
<section className="relative flex flex-col justify-start overflow-hidden -mt-[80px] pt-[80px]">
{/* Background glow */}
<div
className="glow-orb w-[600px] h-[400px] left-1/4 top-1/3 opacity-15"

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import UserDropdown from '@/components/brand/UserDropdown';
@ -50,7 +50,14 @@ export default function Header() {
const handleNavClick = (anchor: string | null, to: string, external?: boolean) => {
if (external) return;
if (!anchor) { navigate(to); return; }
if (!anchor) {
if (to === '/' && location.pathname === '/') {
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
navigate(to);
}
return;
}
if (location.pathname === '/') {
document.getElementById(anchor)?.scrollIntoView({ behavior: 'smooth' });
} else {
@ -58,7 +65,8 @@ export default function Header() {
}
};
const limelightItems = navLinks.map(({ label, to, anchor, external }, i) => ({
// Memoise icons (static JSX) to prevent LimelightNav useLayoutEffect thrash on scroll
const limelightItems = useMemo(() => navLinks.map(({ label, to, anchor, external }) => ({
id: label,
label,
onClick: external ? () => { window.location.href = to; } : () => handleNavClick(anchor, to),
@ -67,13 +75,13 @@ export default function Header() {
{label}
</span>
),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
})), [location.pathname]);
return (
<header
className={cn(
'fixed top-0 left-0 right-0 z-50 transition-all duration-300',
scrolled ? 'py-2' : 'py-2'
'fixed top-0 left-0 right-0 z-50 py-2 transition-all duration-300',
)}
>
{!shouldReduce && (
@ -92,13 +100,13 @@ export default function Header() {
: 'bg-transparent'
)}
>
{/* Left: Logo banner */}
<Link to="/" className="flex-shrink-0 md:self-start">
{/* Left: Logo banner — contained within the 64px navbar frame */}
<Link to="/" className="flex-shrink-0">
<img
src={`${import.meta.env.BASE_URL}cohorta-banner.png`}
alt="Cohorta"
style={{ objectFit: 'contain', objectPosition: 'left center' }}
className="w-auto h-[48px] md:h-[168px]"
className="w-auto h-[44px]"
draggable={false}
/>
</Link>

View file

@ -41,9 +41,12 @@ export function LimelightNav({
if (limelight && activeItem) {
const newLeft = activeItem.offsetLeft + activeItem.offsetWidth / 2 - limelight.offsetWidth / 2;
limelight.style.left = `${newLeft}px`;
if (!isReady) setTimeout(() => setIsReady(true), 50);
if (!isReady) {
const id = setTimeout(() => setIsReady(true), 50);
return () => clearTimeout(id);
}
}
}, [current, isReady, items]);
}, [current, isReady]);
if (items.length === 0) return null;

View file

@ -126,8 +126,8 @@ export const authApi = {
getProfile: () =>
api.get('/auth/me'),
resendVerification: () =>
api.post('/auth/resend-verification'),
resendVerification: (email?: string) =>
api.post('/auth/resend-verification', email ? { email } : {}),
};
// Billing endpoints

View file

@ -284,7 +284,7 @@ const FocusGroups = () => {
};
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen bg-background">
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">

View file

@ -1150,7 +1150,7 @@ const SyntheticUsers = () => {
// Removed separate download function - now using direct file response
return (
<div className="min-h-screen bg-slate-50">
<div className="min-h-screen bg-background">
<main className="pt-20 pb-16 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">