321 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|