Files
kan/apps/web/src/views/onboarding/select-plan/index.tsx
2026-05-18 23:20:11 +01:00

321 lines
13 KiB
TypeScript

import { useRouter, useSearchParams } from "next/navigation";
import { Radio, RadioGroup } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
import { HiUser } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button";
import { api } from "~/utils/api";
type PlanId = "solo" | "team" | "pro";
type Billing = "monthly" | "annual";
// Each user orbits via a zero-size pivot div at center (144,144) that rotates.
// The icon is offset from the pivot by `radius` pixels along the x-axis.
// A counter-rotation on the icon keeps it upright.
const ORBIT_USERS: Record<
PlanId,
{ id: string; angle: number; radius: number; duration: number }[]
> = {
solo: [],
team: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
],
pro: [
{ id: "t1", angle: 45, radius: 104, duration: 50 },
{ id: "t2", angle: 170, radius: 104, duration: 50 },
{ id: "t3", angle: 290, radius: 104, duration: 50 },
{ id: "p1", angle: 10, radius: 144, duration: 70 },
{ id: "p2", angle: 120, radius: 144, duration: 70 },
{ id: "p4", angle: 240, radius: 144, duration: 70 },
{ id: "p3", angle: 230, radius: 64, duration: 30 },
],
};
export default function SelectPlanView() {
const router = useRouter();
const searchParams = useSearchParams();
const [selected, setSelected] = useState<PlanId>(
(searchParams.get("plan") as PlanId | null) ?? "solo",
);
const [billing, setBilling] = useState<Billing>(
(searchParams.get("billing") as Billing | null) ?? "annual",
);
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
const workspacePublicId = searchParams.get("workspacePublicId");
const { data: workspaces } = api.workspace.all.useQuery();
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const userImage = user?.image ?? session?.user.image ?? null;
const hasExistingWorkspace = !!workspaces?.length;
const FREQUENCIES: { value: Billing; label: string }[] = [
{ value: "monthly", label: t`Monthly` },
{ value: "annual", label: t`Annual` },
];
const PLANS: {
id: PlanId;
name: string;
monthly: string;
annual: string;
description: string;
trial?: boolean;
}[] = [
{
id: "solo",
name: t`Solo`,
monthly: t`Free`,
annual: t`Free`,
description: t`Good for individuals starting out who just need the essentials.`,
},
{
id: "team",
name: t`Team`,
monthly: "$10/user/mo",
annual: "$8/user/mo",
description: t`Best for small teams who want to collaborate and move faster together.`,
trial: true,
},
{
id: "pro",
name: t`Pro`,
monthly: "$29/mo",
annual: "$23/mo",
description: t`Unlimited members and a custom workspace username for teams ready to scale.`,
trial: true,
},
];
const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
return workspacePublicId
? `${base}&workspacePublicId=${workspacePublicId}`
: base;
};
const handleSelectPlan = (plan: PlanId) => {
setSelected(plan);
router.replace(buildSelectPlanUrl(plan, billing));
};
const handleSetBilling = (b: Billing) => {
setBilling(b);
router.replace(buildSelectPlanUrl(selected, b));
};
const handleContinue = async () => {
if (workspacePublicId && selected !== "solo") {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
plan: selected,
billing,
workspacePublicId,
successUrl: returnUrl,
cancelUrl: returnUrl,
}),
});
const { url } = (await response.json()) as { url: string };
if (url) window.location.href = url;
return;
}
router.push(
`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
);
};
const handleCancel = () => router.push(returnUrl);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Choose a plan`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`Pick a plan to get started. All paid plans include a 14-day free trial.`}
</p>
{/* Billing toggle */}
<div className="mt-4 flex justify-end">
<RadioGroup
value={billing}
onChange={handleSetBilling}
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
>
{FREQUENCIES.map((f) => (
<Radio
key={f.value}
value={f.value}
className={twMerge(
"cursor-pointer rounded-full px-2.5 py-0.5 text-xs transition-colors",
billing === f.value
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
: "text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200",
)}
>
{f.label}
</Radio>
))}
</RadioGroup>
</div>
<div className="mt-4 space-y-3">
{PLANS.map((plan) => {
const badge =
billing === "annual" ? plan.annual : plan.monthly;
return (
<button
key={plan.id}
type="button"
onClick={() => handleSelectPlan(plan.id)}
className={`relative w-full rounded-lg border px-4 py-3 text-left transition-colors ${
selected === plan.id
? "border-light-700 bg-light-300 dark:border-dark-600 dark:bg-dark-200"
: "border-light-500 bg-light-200 hover:border-light-600 dark:border-dark-500 dark:bg-dark-100 dark:hover:border-dark-600"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 pr-8">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
{plan.name}
</span>
<span className="rounded-full bg-neutral-700 px-2 py-px text-[11px] font-medium text-neutral-200">
{badge}
</span>
{plan.trial && billing === "annual" && (
<span className="rounded-full bg-emerald-500/10 px-2 py-px text-[11px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400">
-20%
</span>
)}
</div>
<p className="mt-1 text-xs text-light-800 dark:text-dark-800">
{plan.description}
</p>
</div>
<div className="mt-0.5 flex-shrink-0">
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
selected === plan.id
? "border-light-900 bg-light-900 dark:border-dark-900 dark:bg-dark-900"
: "border-light-700 dark:border-dark-700"
}`}
>
{selected === plan.id && (
<div className="h-1.5 w-1.5 rounded-full bg-light-100 dark:bg-dark-100" />
)}
</div>
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="mt-6 flex justify-end gap-2 md:mt-8">
{hasExistingWorkspace && (
<Button variant="ghost" onClick={handleCancel}>
{t`Cancel`}
</Button>
)}
<Button onClick={() => void handleContinue()}>
{workspacePublicId && selected !== "solo"
? t`Upgrade`
: t`Continue`}
</Button>
</div>
</div>
{/* Right panel */}
<div className="hidden items-center justify-center bg-light-200 dark:bg-dark-200 md:flex md:w-[45%]">
<div className="relative h-72 w-72">
{/* Orbit rings */}
<div className="absolute inset-0 m-auto h-72 w-72 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-52 w-52 rounded-full border border-light-400 dark:border-dark-400" />
<div className="absolute inset-0 m-auto h-32 w-32 rounded-full border border-light-400 dark:border-dark-400" />
{/* Centre person */}
<div className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-light-300 dark:bg-dark-300">
{userImage ? (
<img
src={userImage}
alt={t`Your avatar`}
className="h-full w-full object-cover"
/>
) : (
<HiUser className="h-7 w-7 text-light-700 dark:text-dark-700" />
)}
</div>
{/* Orbiting users — zero-size pivot at center rotates; icon offset by radius stays on the ring */}
<AnimatePresence>
{ORBIT_USERS[selected].map((u) => (
<motion.div
key={u.id}
style={{ position: "absolute", left: 144, top: 144 }}
initial={{ opacity: 0, rotate: u.angle }}
animate={{ opacity: 1, rotate: u.angle + 360 }}
exit={{ opacity: 0 }}
transition={{
opacity: { duration: 0.3 },
rotate: {
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
},
}}
>
{/* Counter-rotate icon so it stays upright */}
<motion.div
style={{
position: "absolute",
left: u.radius - 12,
top: -12,
}}
initial={{ rotate: -u.angle }}
animate={{ rotate: -(u.angle + 360) }}
transition={{
duration: u.duration,
repeat: Infinity,
ease: "linear",
repeatType: "loop",
}}
className="flex h-6 w-6 items-center justify-center rounded-full bg-light-400 dark:bg-dark-400"
>
<HiUser className="h-3.5 w-3.5 text-light-800 dark:text-dark-800" />
</motion.div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</div>
{!hasExistingWorkspace && (
<Button variant="ghost" onClick={() => authClient.signOut()}>
{t`Sign out`}
</Button>
)}
</div>
);
}