Files
Kordant/web/src/routes/(webapp)/settings.tsx

185 lines
6.9 KiB
TypeScript

import { createSignal, Show } from "solid-js";
import { Title } from "@solidjs/meta";
import { A } from "@solidjs/router";
import { Sidebar, TopBar } from "~/components/dashboard";
import { Button, Card, Input, Badge } from "~/components/ui";
import { useAuth, useSubscription } from "~/hooks";
import { api } from "~/lib/api";
export default function SettingsPage() {
const [sidebarOpen, setSidebarOpen] = createSignal(false);
const auth = useAuth();
const subscription = useSubscription();
const [name, setName] = createSignal(auth.user()?.name ?? "");
const [saving, setSaving] = createSignal(false);
const [portalLoading, setPortalLoading] = createSignal(false);
const [cancelLoading, setCancelLoading] = createSignal(false);
async function saveProfile() {
setSaving(true);
try {
await api.user.update.mutate({ name: name() });
} finally {
setSaving(false);
}
}
async function openBillingPortal() {
setPortalLoading(true);
try {
const result = await api.billing.createPortalSession.mutate({
returnUrl: `${window.location.origin}/settings`,
});
window.location.href = result.url;
} catch {
setPortalLoading(false);
}
}
async function handleCancelSubscription() {
const sub = subscription.subscription();
if (!sub || !sub.stripeId) return;
setCancelLoading(true);
try {
await api.billing.cancelSubscription.mutate({
subscriptionId: sub.stripeId,
});
} catch {
// Error handled by trpc
} finally {
setCancelLoading(false);
}
}
function getStatusBadgeClass(status: string): string {
switch (status) {
case "active": return "bg-green-100 text-green-800";
case "trialing": return "bg-blue-100 text-blue-800";
case "past_due": return "bg-yellow-100 text-yellow-800";
case "canceled": return "bg-red-100 text-red-800";
case "unpaid": return "bg-red-100 text-red-800";
default: return "bg-gray-100 text-gray-800";
}
}
return (
<div class="flex h-[calc(100vh-4rem)] bg-[var(--color-bg)]">
<Title>Settings Kordant</Title>
<Sidebar open={sidebarOpen()} onClose={() => setSidebarOpen(false)} />
<div class="flex-1 flex flex-col min-w-0">
<TopBar onMenuToggle={() => setSidebarOpen(v => !v)} />
<main id="main-content" class="flex-1 overflow-y-auto p-6">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold text-[var(--color-text-primary)] mb-6">Settings</h1>
<Card class="mb-6 p-4">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)] mb-4">Profile</h2>
<div class="flex flex-col gap-4">
<Input
label="Name"
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
<Input
label="Email"
value={auth.user()?.email ?? ""}
disabled
/>
<Button onClick={saveProfile} loading={saving()}>
Save
</Button>
</div>
</Card>
<Card class="p-4 mb-6">
<h2 class="text-sm font-semibold text-[var(--color-text-primary)] mb-4">Subscription</h2>
<Show
when={subscription.subscription()}
fallback={
<div>
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
You're on the free plan. Upgrade to unlock all features.
</p>
<A href="/pricing">
<Button variant="primary">View Plans</Button>
</A>
</div>
}
>
{(sub) => (
<div>
<div class="flex items-center justify-between mb-4">
<div>
<p class="text-lg font-semibold text-[var(--color-text-primary)]">
{sub().tier?.charAt(0).toUpperCase() + sub().tier?.slice(1) || "Free"}
{sub().isTrialing ? " (Trial)" : ""}
</p>
<p class="text-sm text-[var(--color-text-secondary)]">
<span class={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${getStatusBadgeClass(sub().status ?? "active")}`}>
{sub().status}
</span>
</p>
</div>
<Show when={sub().cancelAtPeriodEnd}>
<Badge variant="info">Cancels at period end</Badge>
</Show>
</div>
<Show when={sub().currentPeriodEnd}>
{(end) => (
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
{sub().isTrialing && sub().trialEnd
? `Trial ends ${new Date(sub().trialEnd as any).toLocaleDateString()}`
: `Next billing date: ${new Date(end() as any).toLocaleDateString()}`}
</p>
)}
</Show>
<Show when={sub().defaultPaymentMethodLast4}>
{(last4) => (
<p class="text-sm text-[var(--color-text-secondary)] mb-4">
Payment method: {last4()}
</p>
)}
</Show>
<div class="flex flex-wrap gap-3">
<Button
variant="secondary"
onClick={openBillingPortal}
loading={portalLoading()}
>
Manage Billing
</Button>
<Show when={sub().status === "active" || sub().status === "trialing"}>
<Show when={!sub().cancelAtPeriodEnd}>
<Button
variant="danger"
onClick={handleCancelSubscription}
loading={cancelLoading()}
>
Cancel Subscription
</Button>
</Show>
</Show>
</div>
<Show when={sub().cancelAtPeriodEnd}>
<p class="text-xs text-[var(--color-text-secondary)] mt-3">
Your subscription will remain active until the end of your billing period.
</p>
</Show>
</div>
)}
</Show>
</Card>
</div>
</main>
</div>
</div>
);
}