lmao
This commit is contained in:
parent
03445188ea
commit
524580eb25
102 changed files with 36625 additions and 1295 deletions
216
frontend/src/components/invite/InvitePage.tsx
Normal file
216
frontend/src/components/invite/InvitePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue