feat: add workspace details step

This commit is contained in:
Henry
2026-04-01 23:30:00 +01:00
parent abaf2badb0
commit 1e93d85318
4 changed files with 394 additions and 50 deletions

View File

@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
</button>
)}
{iconRight && type !== "password" && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
{iconRight}
</div>
)}

View File

@@ -0,0 +1,28 @@
import { useRouter } from "next/navigation";
import { env } from "next-runtime-env";
import { useEffect } from "react";
import { authClient } from "@kan/auth/client";
import { PageHead } from "~/components/PageHead";
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
export default function WorkspaceDetailsPage() {
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
useEffect(() => {
if (!isPending && !session?.user) router.push("/login");
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
router.push("/boards");
}, [session, isPending, router]);
if (isPending || !session?.user) return null;
return (
<>
<PageHead title="Create workspace | kan.bn" />
<WorkspaceDetailsView />
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useRouter } from "next/navigation";
import { t } from "@lingui/core/macro";
import { Radio, RadioGroup } from "@headlessui/react";
import { AnimatePresence, motion } from "framer-motion";
import { useState } from "react";
@@ -37,47 +38,6 @@ const ORBIT_USERS: Record<
],
};
const FREQUENCIES: { value: Billing; label: string }[] = [
{ value: "monthly", label: "Monthly" },
{ value: "annual", label: "Annual" },
];
const PLANS: {
id: PlanId;
name: string;
monthly: string;
annual: string;
description: string;
trial?: boolean;
}[] = [
{
id: "solo",
name: "Solo",
monthly: "Free",
annual: "Free",
description:
"Good for individuals starting out who just need the essentials.",
},
{
id: "team",
name: "Team",
monthly: "$12/user/mo",
annual: "$10/user/mo",
description:
"Best for small teams who want to collaborate and move faster together.",
trial: true,
},
{
id: "pro",
name: "Pro",
monthly: "$29/mo",
annual: "$23/mo",
description:
"Unlimited members and a custom workspace username for teams ready to scale.",
trial: true,
},
];
export default function SelectPlanView() {
const router = useRouter();
const [selected, setSelected] = useState<PlanId>("solo");
@@ -92,7 +52,46 @@ export default function SelectPlanView() {
const hasExistingWorkspace = !!workspaces?.length;
const handleContinue = () => router.push("/boards");
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: "$12/user/mo",
annual: "$10/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 handleContinue = () =>
router.push(`/onboarding/workspace?plan=${selected}&billing=${billing}`);
const handleCancel = () => {
if (window.history.length > 1) {
@@ -105,16 +104,15 @@ export default function SelectPlanView() {
return (
<div className="flex min-h-screen items-center justify-center bg-light-100 dark:bg-dark-50">
<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:flex-row">
<div className="flex flex-col md:h-[520px] md:flex-row">
{/* Left panel */}
<div className="flex flex-col p-8 md:w-[55%]">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
Choose a plan
{t`Choose a plan`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
Pick a plan to get started. All paid plans include a 14-day free
trial.
{t`Pick a plan to get started. All paid plans include a 14-day free trial.`}
</p>
{/* Billing toggle */}
@@ -198,10 +196,10 @@ export default function SelectPlanView() {
<div className="mt-8 flex justify-end gap-2">
{hasExistingWorkspace && (
<Button variant="ghost" onClick={handleCancel}>
Cancel
{t`Cancel`}
</Button>
)}
<Button onClick={handleContinue}>Continue</Button>
<Button onClick={handleContinue}>{t`Continue`}</Button>
</div>
</div>
@@ -218,7 +216,7 @@ export default function SelectPlanView() {
{userImage ? (
<img
src={userImage}
alt="Your avatar"
alt={t`Your avatar`}
className="h-full w-full object-cover"
/>
) : (

View File

@@ -0,0 +1,318 @@
import { useRouter, useSearchParams } from "next/navigation";
import { t } from "@lingui/core/macro";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useMemo, useState } from "react";
import {
HiArrowLeft,
HiArrowPath,
HiArrowRight,
HiCheck,
HiEllipsisVertical,
HiInformationCircle,
HiLockClosed,
} from "react-icons/hi2";
import { authClient } from "@kan/auth/client";
import { generateUID } from "@kan/shared/utils";
import Button from "~/components/Button";
import Input from "~/components/Input";
import LoadingSpinner from "~/components/LoadingSpinner";
import Toggle from "~/components/Toggle";
import { Tooltip } from "~/components/Tooltip";
import { useDebounce } from "~/hooks/useDebounce";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
function slugify(value: string) {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60);
}
export default function WorkspaceNameView() {
const router = useRouter();
const searchParams = useSearchParams();
const plan = searchParams.get("plan") ?? "solo";
const billing = searchParams.get("billing") ?? "annual";
const { showPopup } = usePopup();
const [isProToggle, setIsProToggle] = useState(plan === "pro");
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const generatedUid = useMemo(() => generateUID(), []);
const [debouncedSlug] = useDebounce(slug, 500);
const isTyping = slug !== debouncedSlug;
const slugAvailability = api.workspace.checkSlugAvailability.useQuery(
{ workspaceSlug: debouncedSlug },
{ enabled: isProToggle && debouncedSlug.length >= 3 && !isTyping },
);
const isSlugAvailable =
slugAvailability.data?.isAvailable && !slugAvailability.data?.isReserved;
const isSlugTaken =
slugAvailability.data?.isAvailable === false &&
!slugAvailability.data?.isReserved;
const isSlugReserved = slugAvailability.data?.isReserved === true;
const slugError = isSlugTaken
? t`This URL has already been taken`
: isSlugReserved
? t`This URL is reserved`
: undefined;
const { data: session } = authClient.useSession();
const { data: user } = api.user.getUser.useQuery(undefined, {
enabled: !!session?.user,
});
const utils = api.useUtils();
const previewSlug = isProToggle
? slugify(slug) || slugify(name) || "your-workspace"
: "your-workspace";
const createWorkspace = api.workspace.create.useMutation({
onSuccess: async (workspace) => {
if (!workspace.publicId) return;
localStorage.setItem("workspacePublicId", workspace.publicId);
localStorage.removeItem("onboardingWorkspaceSlug");
void utils.workspace.all.invalidate();
if (plan !== "solo") {
try {
const response = await fetch("/api/stripe/create_checkout_session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workspacePublicId: workspace.publicId,
cancelUrl: "/boards",
successUrl: "/boards",
billing,
}),
});
const data = await response.json();
const url = (data as { url: string }).url;
if (url) {
window.location.href = url;
return;
}
} catch {
// fall through
}
}
router.push("/boards");
},
onError: () => {
showPopup({
header: t`Unable to create workspace`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
});
const handleContinue = () => {
if (!name.trim()) return;
createWorkspace.mutate({ name: name.trim() });
};
useEffect(() => {
document.getElementById("workspace-name-input")?.focus();
}, []);
const displayName = user?.name ?? session?.user.name ?? "";
const BOARDS = [t`Roadmap`, t`Engineering`, t`Marketing`];
const [visibleCount, setVisibleCount] = useState(1);
useEffect(() => {
const timers = BOARDS.slice(1).map((_, i) =>
setTimeout(() => setVisibleCount(i + 2), (i + 1) * 2000),
);
return () => timers.forEach(clearTimeout);
}, []);
return (
<div className="flex min-h-screen items-center justify-center bg-light-100 dark:bg-dark-50">
<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-8 md:w-[55%]">
<div className="flex-1">
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
{t`Set up your workspace`}
</h2>
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
{t`You can always change these later in settings.`}
</p>
<div className="mt-6 space-y-3">
<Input
id="workspace-name-input"
placeholder={t`Workspace name`}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleContinue()}
maxLength={64}
/>
<div className="space-y-2">
<Input
placeholder={t`your-workspace`}
value={isProToggle ? slug : t`your-workspace`}
onChange={(e) => setSlug(e.target.value)}
disabled={!isProToggle}
prefix="kan.bn/"
className={
!isProToggle ? "cursor-not-allowed opacity-50" : ""
}
errorMessage={slugError}
iconRight={
!isProToggle ? (
<Tooltip
content={
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
}
placement="top"
delay={0}
>
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
</Tooltip>
) : isProToggle && slug.length >= 3 ? (
isTyping || slugAvailability.isPending ? (
<LoadingSpinner />
) : isSlugAvailable ? (
<HiCheck className="h-4 w-4 text-white" />
) : null
) : null
}
/>
</div>
<div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t`Workspace description`}
maxLength={280}
rows={3}
className="block w-full resize-none rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
/>
<p className="mt-1 text-right text-[10px] text-light-700 dark:text-dark-700">
{description.length}/280
</p>
</div>
</div>
</div>
<div className="mt-6 flex items-center justify-between">
{plan !== "pro" && (
<Toggle
isChecked={isProToggle}
onChange={() => setIsProToggle((v) => !v)}
label={t`Upgrade to Pro ($29/month)`}
/>
)}
<div className="ml-auto flex gap-2">
<Button variant="ghost" onClick={() => router.back()}>
{t`Back`}
</Button>
<Button
onClick={handleContinue}
disabled={
!name.trim() ||
createWorkspace.isPending ||
(isProToggle &&
slug.length >= 3 &&
(isTyping || slugAvailability.isPending || !!slugError))
}
isLoading={createWorkspace.isPending}
>
{t`Continue`}
</Button>
</div>
</div>
</div>
{/* Right panel — browser mockup */}
<div className="hidden flex-col bg-light-300 dark:bg-dark-200 md:flex md:w-[45%]">
{/* Browser chrome */}
<div className="flex items-center gap-2 border-b border-light-400 px-3 py-2.5 dark:border-dark-400">
<div className="flex items-center gap-1.5 text-light-700 dark:text-dark-700">
<HiArrowLeft className="h-3.5 w-3.5" />
<HiArrowRight className="h-3.5 w-3.5" />
<HiArrowPath className="h-3.5 w-3.5" />
</div>
<div className="flex flex-1 items-center gap-1.5 rounded-md bg-light-200 px-2.5 py-1 dark:bg-dark-300">
<HiLockClosed className="h-3 w-3 flex-shrink-0 text-light-700 dark:text-dark-700" />
<span className="truncate text-xs text-light-900 dark:text-dark-900">
kan.bn/
<span className="text-light-1000 dark:text-dark-1000">
{previewSlug}
</span>
</span>
</div>
<HiEllipsisVertical className="h-4 w-4 flex-shrink-0 text-light-700 dark:text-dark-700" />
</div>
{/* Browser content */}
<div className="flex flex-1 flex-col items-center overflow-hidden px-3 py-4">
{/* Workspace name + description */}
<p className="text-center text-xs font-bold text-light-1000 dark:text-dark-1000">
{name || t`Your workspace`}
</p>
<p className="mt-0.5 line-clamp-2 break-all text-center text-[10px] text-light-800 dark:text-dark-800">
{description || t`Your workspace description`}
</p>
{/* Boards area */}
<div className="mt-3 w-full flex-1 rounded-lg bg-light-200 p-2 dark:bg-dark-300">
<div className="space-y-1.5">
<AnimatePresence>
{[...BOARDS.slice(0, visibleCount)].reverse()
.map((board) => (
<motion.div
key={board}
layout
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
layout: {
type: "spring",
stiffness: 300,
damping: 30,
},
opacity: { duration: 0.2 },
y: { duration: 0.25, ease: "easeOut" },
}}
className="flex w-full items-center justify-center rounded border border-dashed border-light-400 bg-light-50 px-3 py-10 text-[10px] font-medium text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"
>
{board}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
{/* Footer */}
<p className="mt-2 text-[10px] font-semibold text-light-900 dark:text-dark-900">
kan.bn
</p>
</div>
</div>
</div>
</div>
</div>
);
}