This commit is contained in:
Andras Schmelczer 2026-02-15 22:39:49 +00:00
parent 03445188ea
commit 524580eb25
102 changed files with 36625 additions and 1295 deletions

View file

@ -0,0 +1,216 @@
import { useState, useEffect, useCallback } from 'react';
import { apiUrl, authHeaders, assertOk } from '../../lib/api';
import { SpinnerIcon } from '../ui/icons/SpinnerIcon';
import { CheckIcon } from '../ui/icons/CheckIcon';
import type { AuthUser } from '../../hooks/useAuth';
interface InvitePageProps {
code: string;
user: AuthUser | null;
onLoginClick: () => void;
onRegisterClick: () => void;
onLicenseGranted: () => void;
}
interface InviteInfo {
valid: boolean;
invite_type: string;
used: boolean;
}
export default function InvitePage({
code,
user,
onLoginClick,
onRegisterClick,
onLicenseGranted,
}: InvitePageProps) {
const [invite, setInvite] = useState<InviteInfo | null>(null);
const [loading, setLoading] = useState(true);
const [redeeming, setRedeeming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [redeemed, setRedeemed] = useState(false);
const [pricePence, setPricePence] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [inviteRes, pricingRes] = await Promise.all([
fetch(apiUrl(`invite/${encodeURIComponent(code)}`)),
fetch(apiUrl('pricing')),
]);
if (!inviteRes.ok) throw new Error('Failed to validate invite');
const data: InviteInfo = await inviteRes.json();
if (!cancelled) setInvite(data);
if (pricingRes.ok) {
const pricing = await pricingRes.json();
if (!cancelled) setPricePence(pricing.current_price_pence);
}
} catch {
if (!cancelled) setError('Failed to validate invite link');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [code]);
const handleRedeem = useCallback(async () => {
if (!user) return;
setRedeeming(true);
setError(null);
try {
const res = await fetch(apiUrl('redeem-invite'), {
method: 'POST',
...authHeaders({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
}),
});
assertOk(res, 'Redeem invite');
const data = await res.json();
if (data.result === 'licensed') {
setRedeemed(true);
onLicenseGranted();
} else if (data.checkout_url) {
window.location.href = data.checkout_url;
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to redeem invite');
} finally {
setRedeeming(false);
}
}, [code, user, onLicenseGranted]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<SpinnerIcon className="w-8 h-8 text-teal-600 dark:text-teal-400 animate-spin" />
</div>
);
}
if (error && !invite) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">Invalid invite</p>
<p className="text-warm-500 dark:text-warm-400">{error}</p>
</div>
</div>
);
}
if (!invite?.valid || invite.used) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
{invite?.used ? 'Invite already used' : 'Invalid invite link'}
</p>
<p className="text-warm-500 dark:text-warm-400">
{invite?.used
? 'This invite link has already been redeemed.'
: 'This invite link is invalid or has expired.'}
</p>
</div>
</div>
);
}
if (redeemed) {
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="text-center max-w-sm mx-4">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-teal-100 dark:bg-teal-900/30 flex items-center justify-center">
<CheckIcon className="w-8 h-8 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-lg font-medium text-navy-950 dark:text-warm-100 mb-2">
License activated!
</p>
<p className="text-warm-500 dark:text-warm-400">
You now have full access to Perfect Postcode.
</p>
</div>
</div>
);
}
const isAdminInvite = invite.invite_type === 'admin';
return (
<div className="flex-1 flex items-center justify-center bg-warm-50 dark:bg-navy-950">
<div className="w-full max-w-md mx-4 bg-white dark:bg-warm-800 rounded-2xl border border-warm-200 dark:border-warm-700 shadow-lg overflow-hidden">
<div className="bg-gradient-to-br from-navy-950 to-teal-900 px-6 py-8 text-center">
<h2 className="text-2xl font-bold text-white mb-2">
{isAdminInvite ? "You're invited!" : 'Special offer!'}
</h2>
<p className="text-warm-300 text-sm">
{isAdminInvite
? 'You have been invited to get a free lifetime license.'
: 'A friend has shared a 30% discount on the lifetime license.'}
</p>
</div>
<div className="px-6 py-6">
{isAdminInvite && (
<div className="text-center mb-4">
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">Free</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">lifetime license</span>
</div>
)}
{!isAdminInvite && pricePence !== null && pricePence > 0 && (
<div className="text-center mb-4">
<span className="text-warm-400 dark:text-warm-500 line-through text-xl mr-2">
{`\u00A3${pricePence / 100}`}
</span>
<span className="text-3xl font-extrabold text-teal-600 dark:text-teal-400">
{`\u00A3${Math.round(pricePence * 0.7) / 100}`}
</span>
<span className="text-warm-500 dark:text-warm-400 ml-1">/once</span>
</div>
)}
{user ? (
<button
onClick={handleRedeem}
disabled={redeeming}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25 disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
>
{redeeming && <SpinnerIcon className="w-5 h-5 animate-spin" />}
{isAdminInvite
? redeeming
? 'Activating...'
: 'Activate license'
: redeeming
? 'Redirecting...'
: 'Claim discount'}
</button>
) : (
<div className="flex flex-col gap-3">
<button
onClick={onRegisterClick}
className="w-full px-6 py-3 bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-lg shadow-lg shadow-coral-500/25"
>
Register to claim
</button>
<button
onClick={onLoginClick}
className="w-full px-4 py-2 text-sm text-teal-600 dark:text-teal-400 hover:text-teal-800 dark:hover:text-teal-300"
>
Already have an account? Log in
</button>
</div>
)}
{error && (
<p className="mt-3 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</div>
</div>
</div>
);
}