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:
parent
28bb04a2b2
commit
4e910704bc
8 changed files with 53 additions and 29 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue