diff --git a/app/(workbench)/layout.tsx b/app/(workbench)/layout.tsx new file mode 100644 index 0000000..774b48b --- /dev/null +++ b/app/(workbench)/layout.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; + +import { Workbench } from "@/components/app/Workbench"; +import { getEntitlementSummary } from "@/lib/server/entitlements"; +import { getCurrentUser } from "@/lib/supabase/server"; +import { buildWorkspaceTree } from "@/lib/workbench/tree"; + +import { getDashboardData } from "../dashboard/data"; + +export const dynamic = "force-dynamic"; + +export default async function WorkbenchLayout({ + children, +}: { + children: ReactNode; +}) { + // The layout can't know the requested path, so it must not own the auth + // redirect — each page calls requireDashboardUser() with the correct `next`. + // When signed out we render children bare and let that page-level guard fire. + const user = await getCurrentUser(); + if (!user) { + return <>{children}; + } + + const [data, summary] = await Promise.all([ + getDashboardData(user), + getEntitlementSummary(user.id), + ]); + const tree = buildWorkspaceTree(data.workspaces, data.projects); + + return ( + + {children} + + ); +} diff --git a/app/projects/[projectId]/page.tsx b/app/(workbench)/projects/[projectId]/page.tsx similarity index 58% rename from app/projects/[projectId]/page.tsx rename to app/(workbench)/projects/[projectId]/page.tsx index 490e21f..d4e949c 100644 --- a/app/projects/[projectId]/page.tsx +++ b/app/(workbench)/projects/[projectId]/page.tsx @@ -1,9 +1,13 @@ -import { AppShell } from "@/components/dashboard/app-shell"; import { CreateVersionForm } from "@/components/project/create-version-form"; import { ProjectHeader } from "@/components/project/project-header"; import { VersionTimeline } from "@/components/project/version-timeline"; +import { UpgradeGate } from "@/components/app/UpgradeGate"; -import { getProjectPageData, requireDashboardUser } from "../../dashboard/data"; +import { + getProjectPageData, + requireDashboardUser, + resolvePlanGate, +} from "@/app/dashboard/data"; export const dynamic = "force-dynamic"; @@ -15,9 +19,11 @@ type ProjectPageProps = { export default async function ProjectPage({ params }: ProjectPageProps) { const { projectId } = await params; - const user = await requireDashboardUser(`/projects/${projectId}`, { - paidOnly: true, - }); + const user = await requireDashboardUser(`/projects/${projectId}`); + const { isPaid } = await resolvePlanGate(user.id); + if (!isPaid) { + return ; + } const data = await getProjectPageData(projectId, user); const canWrite = data.project.owner_id === user.id || @@ -25,12 +31,10 @@ export default async function ProjectPage({ params }: ProjectPageProps) { (data.workspace.plan_tier === "studio" && data.role === "admin"); return ( - -
- - {canWrite ? : null} - -
-
+
+ + {canWrite ? : null} + +
); } diff --git a/app/projects/[projectId]/versions/[versionId]/page.tsx b/app/(workbench)/projects/[projectId]/versions/[versionId]/page.tsx similarity index 51% rename from app/projects/[projectId]/versions/[versionId]/page.tsx rename to app/(workbench)/projects/[projectId]/versions/[versionId]/page.tsx index 81e1927..a59a182 100644 --- a/app/projects/[projectId]/versions/[versionId]/page.tsx +++ b/app/(workbench)/projects/[projectId]/versions/[versionId]/page.tsx @@ -1,14 +1,15 @@ import Link from "next/link"; -import { AppShell } from "@/components/dashboard/app-shell"; import { ProjectHeader } from "@/components/project/project-header"; import { VersionDetail } from "@/components/project/version-detail"; import { VersionTimeline } from "@/components/project/version-timeline"; +import { UpgradeGate } from "@/components/app/UpgradeGate"; import { getVersionPageData, requireDashboardUser, -} from "../../../../dashboard/data"; + resolvePlanGate, +} from "@/app/dashboard/data"; export const dynamic = "force-dynamic"; @@ -23,25 +24,26 @@ export default async function VersionPage({ params }: VersionPageProps) { const { projectId, versionId } = await params; const user = await requireDashboardUser( `/projects/${projectId}/versions/${versionId}`, - { paidOnly: true }, ); + const { isPaid } = await resolvePlanGate(user.id); + if (!isPaid) { + return ; + } const data = await getVersionPageData(projectId, versionId, user); return ( - -
- - Back to project - - -
- - -
+
+ + Back to project + + +
+ +
- +
); } diff --git a/app/projects/page.tsx b/app/(workbench)/projects/page.tsx similarity index 83% rename from app/projects/page.tsx rename to app/(workbench)/projects/page.tsx index 693831e..9457529 100644 --- a/app/projects/page.tsx +++ b/app/(workbench)/projects/page.tsx @@ -1,20 +1,27 @@ import { ArrowUpRight, Sparkles } from "lucide-react"; import Link from "next/link"; -import { AppShell } from "@/components/dashboard/app-shell"; import { RecentProjects } from "@/components/dashboard/recent-projects"; +import { UpgradeGate } from "@/components/app/UpgradeGate"; -import { getDashboardData, requireDashboardUser } from "../dashboard/data"; +import { + getDashboardData, + requireDashboardUser, + resolvePlanGate, +} from "@/app/dashboard/data"; export const dynamic = "force-dynamic"; export default async function ProjectsPage() { const user = await requireDashboardUser("/projects"); + const { isPaid } = await resolvePlanGate(user.id); + if (!isPaid) { + return ; + } const data = await getDashboardData(user); return ( - -
+

@@ -39,7 +46,6 @@ export default async function ProjectsPage() {

-
- +
); } diff --git a/app/workspaces/[workspaceId]/page.tsx b/app/(workbench)/workspaces/[workspaceId]/page.tsx similarity index 50% rename from app/workspaces/[workspaceId]/page.tsx rename to app/(workbench)/workspaces/[workspaceId]/page.tsx index 40a43d6..d7adfb8 100644 --- a/app/workspaces/[workspaceId]/page.tsx +++ b/app/(workbench)/workspaces/[workspaceId]/page.tsx @@ -1,10 +1,14 @@ -import { AppShell } from "@/components/dashboard/app-shell"; import { CreateProjectForm } from "@/components/project/create-project-form"; import { WorkspaceHeader } from "@/components/workspace/workspace-header"; import { WorkspaceMembers } from "@/components/workspace/workspace-members"; import { WorkspaceProjects } from "@/components/workspace/workspace-projects"; +import { UpgradeGate } from "@/components/app/UpgradeGate"; -import { getWorkspacePageData, requireDashboardUser } from "../../dashboard/data"; +import { + getWorkspacePageData, + requireDashboardUser, + resolvePlanGate, +} from "@/app/dashboard/data"; export const dynamic = "force-dynamic"; @@ -16,24 +20,24 @@ type WorkspacePageProps = { export default async function WorkspacePage({ params }: WorkspacePageProps) { const { workspaceId } = await params; - const user = await requireDashboardUser(`/workspaces/${workspaceId}`, { - paidOnly: true, - }); + const user = await requireDashboardUser(`/workspaces/${workspaceId}`); + const { isPaid } = await resolvePlanGate(user.id); + if (!isPaid) { + return ; + } const data = await getWorkspacePageData(workspaceId, user); return ( - -
- - -
- - -
+
+ + +
+ +
- +
); } diff --git a/app/workspaces/page.tsx b/app/(workbench)/workspaces/page.tsx similarity index 83% rename from app/workspaces/page.tsx rename to app/(workbench)/workspaces/page.tsx index 8153841..4d0b1d4 100644 --- a/app/workspaces/page.tsx +++ b/app/(workbench)/workspaces/page.tsx @@ -1,21 +1,28 @@ import { Plus } from "lucide-react"; import Link from "next/link"; -import { AppShell } from "@/components/dashboard/app-shell"; import { WorkspaceList } from "@/components/dashboard/workspace-list"; import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form"; +import { UpgradeGate } from "@/components/app/UpgradeGate"; -import { getDashboardData, requireDashboardUser } from "../dashboard/data"; +import { + getDashboardData, + requireDashboardUser, + resolvePlanGate, +} from "@/app/dashboard/data"; export const dynamic = "force-dynamic"; export default async function WorkspacesPage() { const user = await requireDashboardUser("/workspaces"); + const { isPaid } = await resolvePlanGate(user.id); + if (!isPaid) { + return ; + } const data = await getDashboardData(user); return ( - -
+

@@ -42,7 +49,6 @@ export default async function WorkspacesPage() {

-
- +
); } diff --git a/app/@modal/(.)account/page.tsx b/app/@modal/(.)account/page.tsx new file mode 100644 index 0000000..f9fee58 --- /dev/null +++ b/app/@modal/(.)account/page.tsx @@ -0,0 +1,17 @@ +import { AccountContent } from "@/components/account/AccountContent"; +import { RouteModal } from "@/components/app/RouteModal"; + +export const dynamic = "force-dynamic"; + +/** + * Intercepts a soft navigation to /account from anywhere in the app and renders + * the Account surface as a modal over the current page. A hard load of /account + * bypasses this and renders the standalone page. + */ +export default function AccountModal() { + return ( + + + + ); +} diff --git a/app/@modal/(.)billing/page.tsx b/app/@modal/(.)billing/page.tsx new file mode 100644 index 0000000..270101f --- /dev/null +++ b/app/@modal/(.)billing/page.tsx @@ -0,0 +1,17 @@ +import { RouteModal } from "@/components/app/RouteModal"; +import { BillingContent } from "@/components/billing/BillingContent"; + +export const dynamic = "force-dynamic"; + +/** + * Intercepts a soft navigation to /billing from anywhere in the app and renders + * the Billing surface as a modal over the current page. A hard load of /billing + * bypasses this and renders the standalone page. + */ +export default function BillingModal() { + return ( + + + + ); +} diff --git a/app/@modal/default.tsx b/app/@modal/default.tsx new file mode 100644 index 0000000..0ad25cf --- /dev/null +++ b/app/@modal/default.tsx @@ -0,0 +1,8 @@ +/** + * Fallback for the `@modal` parallel slot. Every route that isn't an active + * intercept (i.e. everything except a soft navigation to /account or /billing) + * resolves the slot to this, which renders nothing. + */ +export default function ModalDefault() { + return null; +} diff --git a/app/account/page.tsx b/app/account/page.tsx index d97aebb..e4e45f8 100644 --- a/app/account/page.tsx +++ b/app/account/page.tsx @@ -1,24 +1,10 @@ import Link from "next/link"; -import { - ArrowUpRight, - BarChart3, - CreditCard, - Download, - Shield, - Sparkles, - Trash2, - UserRound, -} from "lucide-react"; +import { ArrowUpRight, CreditCard } from "lucide-react"; +import { AccountContent } from "@/components/account/AccountContent"; import { SignOutButton } from "@/components/auth/sign-out-button"; -import { getEntitlementSummary } from "@/lib/server/entitlements"; import { getCurrentUser } from "@/lib/supabase/server"; -import { - requestAccountDeletionAction, - requestDataExportAction, -} from "./actions"; - export const dynamic = "force-dynamic"; type AccountPageProps = { @@ -35,16 +21,16 @@ export default async function AccountPage({ searchParams }: AccountPageProps) { if (!user) { return (
-
+

Account

Sign in required

-

+

Account settings are available after authentication.

Open app @@ -55,28 +41,21 @@ export default async function AccountPage({ searchParams }: AccountPageProps) { ); } - const summary = await getEntitlementSummary(user.id); - const profile = summary.profile; - const subscription = summary.subscription; - const paymentProvider = subscription?.payment_provider ?? null; - const subscriptionId = subscription?.razorpay_subscription_id; - const entitlementRows = [ - ["Daily analyses", summary.entitlements.dailyAnalyses.toLocaleString()], - [ - "Frames per analysis", - summary.entitlements.maxFramesPerAnalysis.toLocaleString(), - ], - [ - "Upload limit", - `${Math.round(summary.entitlements.maxUploadBytes / 1024 / 1024)} MB`, - ], - ["Saved projects", summary.entitlements.savedProjects.toLocaleString()], - ["Team seats", summary.entitlements.teamSeats.toLocaleString()], - [ - "Audit retention", - `${summary.entitlements.auditLogRetentionDays.toLocaleString()} days`, - ], - ]; + const notices = ( + <> + {resolvedSearchParams?.request ? ( +
+ Request received. +
+ ) : null} + + {resolvedSearchParams?.billing === "razorpay" ? ( +
+ Manage your Razorpay subscription from the Billing page. +
+ ) : null} + + ); return (
@@ -93,214 +72,25 @@ export default async function AccountPage({ searchParams }: AccountPageProps) {
Plans
- {resolvedSearchParams?.request ? ( -
- Request received. -
- ) : null} - - {resolvedSearchParams?.billing === "razorpay" ? ( -
- Manage your Razorpay subscription from the Billing page. -
- ) : null} - -
-
- -
- - - -
- -
- - - - - -
+
); } - -function Metric({ - icon, - label, - value, -}: { - icon: React.ReactNode; - label: string; - value: string; -}) { - return ( -
-
- {icon} - - {label} - -
-

{value}

-
- ); -} - -function Panel({ - children, - icon, - title, -}: { - children: React.ReactNode; - icon: React.ReactNode; - title: string; -}) { - return ( -
-
- {icon} -

{title}

-
- {children} -
- ); -} - -function Detail({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function titleCase(value: string) { - return value - .split("_") - .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) - .join(" "); -} - -function formatDate(value: string) { - return new Intl.DateTimeFormat("en", { - dateStyle: "medium", - }).format(new Date(value)); -} diff --git a/app/api/analyze/handler.ts b/app/api/analyze/handler.ts index b75451d..4de4dec 100644 --- a/app/api/analyze/handler.ts +++ b/app/api/analyze/handler.ts @@ -12,9 +12,11 @@ import { authorizeAnalysisRequestWithSupabase, createSupabaseAuditRecorder, createSupabaseUsageEventRecorder, + releaseDailyAnalysisUsageWithSupabase, reserveDailyAnalysisUsageWithSupabase, type AnalysisAuthorizationDecision, type AuditRecorder, + type DailyAnalysisReleaseInput, type DailyAnalysisReservationInput, type UsageEventRecorder, } from "@/lib/server/audit"; @@ -85,6 +87,9 @@ export type AnalyzeRouteDeps = { idGenerator?: () => string; isOpenAiAnalysisEnabled?: () => boolean; now?: () => Date; + releaseDailyAnalysisUsage?: ( + input: DailyAnalysisReleaseInput, + ) => Promise; reserveDailyAnalysisUsage?: ( input: DailyAnalysisReservationInput, ) => Promise; @@ -283,6 +288,11 @@ export async function handleAnalyzeRequest( }); if (!generatedResult.ok) { + await releaseReservationSafely(resolvedDeps.releaseDailyAnalysisUsage, { + since: startOfUtcDay(resolvedDeps.now()), + userId: user.id, + }); + return generatedResult.response; } @@ -317,6 +327,10 @@ export async function handleAnalyzeRequest( "INTERNAL_ERROR", "Failed to persist analysis usage.", ); + await releaseReservationSafely(resolvedDeps.releaseDailyAnalysisUsage, { + since: startOfUtcDay(resolvedDeps.now()), + userId: user.id, + }); await recordFailureAuditSafely(resolvedDeps.audit, { analysisId, analysisModel: analysisProvider.model, @@ -378,6 +392,8 @@ function resolveAnalyzeDeps( isOpenAiAnalysisEnabled: deps.isOpenAiAnalysisEnabled ?? (() => getOpenAiAnalysisGate()), now: deps.now ?? (() => new Date()), + releaseDailyAnalysisUsage: + deps.releaseDailyAnalysisUsage ?? releaseDailyAnalysisUsageWithSupabase, reserveDailyAnalysisUsage: deps.reserveDailyAnalysisUsage ?? reserveDailyAnalysisUsageWithSupabase, usage: deps.usage ?? createSupabaseUsageEventRecorder(), @@ -750,6 +766,18 @@ function toApiError( return new ApiError(fallbackCode, fallbackMessage); } +async function releaseReservationSafely( + release: (input: DailyAnalysisReleaseInput) => Promise, + input: DailyAnalysisReleaseInput, +) { + try { + await release(input); + } catch { + // Best-effort rollback: preserve the original failure response to the + // caller even if releasing the reserved quota slot fails. + } +} + async function recordFailureAuditSafely( audit: AuditRecorder, { diff --git a/app/api/workspaces/handler.ts b/app/api/workspaces/handler.ts index cd17a4c..b0b338d 100644 --- a/app/api/workspaces/handler.ts +++ b/app/api/workspaces/handler.ts @@ -2,6 +2,8 @@ import type { User } from "@supabase/supabase-js"; import { z } from "zod"; import { apiError, apiSuccess, ApiError, isApiError } from "@/lib/server/apiErrors"; +import { ensureProfileForUser } from "@/lib/server/profiles"; +import { createSupabaseAdminClient } from "@/lib/server/supabaseAdmin"; import { createSupabaseServerClient, getCurrentUser as getSupabaseCurrentUser, @@ -37,6 +39,7 @@ type UpdateWorkspaceInput = { export type WorkspaceRouteDeps = { createWorkspace?: (input: CreateWorkspaceInput) => Promise; + ensureProfile?: (userId: string) => Promise; getCurrentUser?: () => Promise; getWorkspaceAccess?: ( userId: string, @@ -112,6 +115,11 @@ export async function handleCreateWorkspaceRequest( const slug = parsed.data.slug ?? slugify(name); try { + // The workspaces.owner_id FK requires a public.profiles row. Password + // sign-in does not pass through /auth/callback, so the profile may not have + // been created yet — make sure it exists before inserting. + await resolvedDeps.ensureProfile(user.id); + const workspace = await resolvedDeps.createWorkspace({ name, ownerId: user.id, @@ -209,6 +217,7 @@ function resolveWorkspaceDeps( ): Required { return { createWorkspace: deps.createWorkspace ?? createWorkspaceWithSupabase, + ensureProfile: deps.ensureProfile ?? ensureProfileWithSupabase, getCurrentUser: deps.getCurrentUser ?? getSupabaseCurrentUser, getWorkspaceAccess: deps.getWorkspaceAccess ?? getWorkspaceAccessWithSupabase, listWorkspaces: deps.listWorkspaces ?? listWorkspacesWithSupabase, @@ -218,6 +227,21 @@ function resolveWorkspaceDeps( }; } +async function ensureProfileWithSupabase(userId: string) { + const admin = createSupabaseAdminClient(); + const { data, error } = await admin.auth.admin.getUserById(userId); + + if (error || !data.user) { + console.error("[workspaces] failed to load auth user for profile ensure", { + code: error?.code, + message: error?.message, + }); + throw new ApiError("INTERNAL_ERROR", "Failed to create workspace."); + } + + await ensureProfileForUser(data.user, admin); +} + async function createWorkspaceWithSupabase(input: CreateWorkspaceInput) { const supabase = await createSupabaseServerClient(); const { data, error } = await supabase @@ -231,6 +255,12 @@ async function createWorkspaceWithSupabase(input: CreateWorkspaceInput) { .single(); if (error || !data) { + console.error("[workspaces] insert failed", { + code: error?.code, + details: error?.details, + hint: error?.hint, + message: error?.message, + }); throw new ApiError("INTERNAL_ERROR", "Failed to create workspace."); } diff --git a/app/app/page.tsx b/app/app/page.tsx index 6ae6406..9d31e91 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -1,4 +1,5 @@ -import { AppShell } from "@/components/app/AppShell"; +import { AppShell as AnalyzeWorkspace } from "@/components/app/AppShell"; +import { AppShell as WorkspaceShell } from "@/components/dashboard/app-shell"; import { getEntitlementSummary } from "@/lib/server/entitlements"; import { getCurrentUser } from "@/lib/supabase/server"; @@ -8,16 +9,28 @@ export default async function AnimationConverter() { const user = await getCurrentUser(); if (!user) { - return ; + return ( + + + + ); } const summary = await getEntitlementSummary(user.id); return ( - + + + ); } diff --git a/app/billing/page.tsx b/app/billing/page.tsx index 49e3fa9..3fa4e07 100644 --- a/app/billing/page.tsx +++ b/app/billing/page.tsx @@ -1,28 +1,12 @@ import Link from "next/link"; import { redirect } from "next/navigation"; -import { ArrowUpRight, CreditCard, Receipt, Shield } from "lucide-react"; +import { ArrowUpRight } from "lucide-react"; -import { type PlanTier } from "@/lib/contracts/plans"; -import { getEntitlementSummary } from "@/lib/server/entitlements"; -import { - getRazorpaySubscriptionSchedule, - listRazorpaySubscriptionInvoices, - type RazorpayInvoiceSummary, -} from "@/lib/server/razorpay"; +import { BillingContent } from "@/components/billing/BillingContent"; import { getCurrentUser } from "@/lib/supabase/server"; -import { CancelSubscriptionButton } from "./CancelSubscriptionButton"; -import { ChangePlanButton } from "./ChangePlanButton"; - export const dynamic = "force-dynamic"; -const MANAGEABLE_STATUSES = new Set([ - "active", - "authenticated", - "past_due", - "trialing", -]); - type BillingPageProps = { searchParams?: Promise<{ plan?: string; @@ -37,28 +21,6 @@ export default async function BillingPage({ searchParams }: BillingPageProps) { } const resolvedSearchParams = await searchParams; - const summary = await getEntitlementSummary(user.id); - const subscription = summary.subscription; - const subscriptionId = subscription?.razorpay_subscription_id ?? null; - const planTier = isPaidPlanTier(subscription?.plan_tier) - ? subscription.plan_tier - : summary.planTier; - const isManageable = - Boolean(subscriptionId) && - typeof subscription?.status === "string" && - MANAGEABLE_STATUSES.has(subscription.status); - const renewalLabel = subscription?.current_period_end - ? formatDate(subscription.current_period_end) - : "the end of the current billing period"; - - const invoices = subscriptionId - ? await safeListInvoices(subscriptionId) - : []; - const hasScheduledChange = - subscriptionId && !subscription?.cancel_at_period_end - ? await safeHasScheduledChange(subscriptionId) - : false; - const notice = resolveNotice(resolvedSearchParams); return ( @@ -75,7 +37,7 @@ export default async function BillingPage({ searchParams }: BillingPageProps) {

Billing

View plans @@ -83,178 +45,12 @@ export default async function BillingPage({ searchParams }: BillingPageProps) { - {notice ? ( -
- {notice} -
- ) : null} - -
-
-
-
- - - - -
-
- - {isManageable && isPaidPlanTier(planTier) ? ( -
-
-
- - {subscription?.cancel_at_period_end ? ( -

- This subscription is scheduled to end on {renewalLabel}. To keep - using a paid plan after that, subscribe again from{" "} - - pricing - - . -

- ) : ( -
-
- {hasScheduledChange ? ( -

- A plan change is already scheduled for the end of your - current billing cycle. It will apply automatically on{" "} - {renewalLabel}. -

- ) : ( - <> -

- {planTier === "pro" - ? "Upgrade to Studio for more seats, workspaces, and analyses. Takes effect immediately." - : "Switch to Pro. The change applies at the end of your current billing cycle."} -

- {planTier === "pro" ? ( - - ) : ( - - )} - - )} -
- -
- -
-
- )} -
- ) : ( -
-
-
-

- You are on the free plan. Choose Pro or Studio to unlock more - analyses, seats, and workspaces. -

- -
- )} - -
-
-
- {invoices.length > 0 ? ( -
    - {invoices.map((invoice) => ( -
  • - - {invoice.issuedAt ? formatDate(invoice.issuedAt) : "—"} - - - {formatAmount(invoice.amount, invoice.currency)} - - - {titleCase(invoice.status)} - - {invoice.shortUrl ? ( - - Download - - ) : ( - {"—"} - )} -
  • - ))} -
- ) : ( -

- No invoices yet. -

- )} -
+
); } -async function safeListInvoices( - subscriptionId: string, -): Promise { - try { - return await listRazorpaySubscriptionInvoices(subscriptionId); - } catch { - return []; - } -} - -async function safeHasScheduledChange(subscriptionId: string): Promise { - try { - const schedule = await getRazorpaySubscriptionSchedule(subscriptionId); - return schedule.hasScheduledChanges; - } catch { - return false; - } -} - function resolveNotice( params: { plan?: string; subscription?: string } | undefined, ) { @@ -269,42 +65,3 @@ function resolveNotice( } return null; } - -function isPaidPlanTier( - value: unknown, -): value is Extract { - return value === "pro" || value === "studio"; -} - -function Detail({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} - -function titleCase(value: string) { - return value - .split("_") - .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) - .join(" "); -} - -function formatDate(value: string) { - return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format( - new Date(value), - ); -} - -function formatAmount(amountInPaise: number, currency: string) { - try { - return new Intl.NumberFormat("en-IN", { - currency, - style: "currency", - }).format(amountInPaise / 100); - } catch { - return `${(amountInPaise / 100).toFixed(2)} ${currency}`; - } -} diff --git a/app/dashboard/data.ts b/app/dashboard/data.ts index 02cadc5..cf49c9f 100644 --- a/app/dashboard/data.ts +++ b/app/dashboard/data.ts @@ -2,6 +2,7 @@ import type { User } from "@supabase/supabase-js"; import * as navigation from "next/navigation"; import { loginPathForNext } from "@/lib/auth/redirects"; +import type { PlanTier } from "@/lib/contracts/plans"; import { getEntitlementSummary } from "@/lib/server/entitlements"; import { createSupabaseServerClient, @@ -70,6 +71,24 @@ export async function requireDashboardUser( return user; } +export type PlanGate = { + isPaid: boolean; + planTier: PlanTier; +}; + +/** + * Resolves whether a user is on a paid plan, for pages that render an in-place + * upgrade gate instead of redirecting. Free users get `isPaid: false`; the page + * is responsible for short-circuiting to when so. + */ +export async function resolvePlanGate(userId: string): Promise { + const summary = await getEntitlementSummary(userId); + return { + isPaid: summary.planTier !== "free", + planTier: summary.planTier, + }; +} + export async function getDashboardData(user: Pick) { const supabase = await createSupabaseServerClient(); const since = new Date(); diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 06ca2df..e5f5638 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -2,21 +2,43 @@ import { ArrowUpRight, Sparkles } from "lucide-react"; import Link from "next/link"; import { AppShell } from "@/components/dashboard/app-shell"; +import { UpgradeGate } from "@/components/app/UpgradeGate"; import { DashboardSummary } from "@/components/dashboard/dashboard-summary"; import { RecentProjects } from "@/components/dashboard/recent-projects"; import { WorkspaceList } from "@/components/dashboard/workspace-list"; import { CreateWorkspaceForm } from "@/components/workspace/create-workspace-form"; +import { getEntitlementSummary } from "@/lib/server/entitlements"; import { getDashboardData, requireDashboardUser } from "./data"; export const dynamic = "force-dynamic"; export default async function DashboardPage() { - const user = await requireDashboardUser("/dashboard", { paidOnly: true }); + const user = await requireDashboardUser("/dashboard"); + const entitlements = await getEntitlementSummary(user.id); + + if (entitlements.planTier === "free") { + return ( + + + + ); + } + const data = await getDashboardData(user); return ( - +
@@ -33,7 +55,7 @@ export default async function DashboardPage() {
- {data.profile?.plan_tier ?? "free"} plan + {entitlements.planTier} plan
) { return ( - {children} + + {children} + {modal} + ); } diff --git a/app/login/page.tsx b/app/login/page.tsx index d7d5fe5..094f2bd 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,6 +1,4 @@ -import Link from "next/link"; - -import { LoginForm } from "@/components/dashboard/login-form"; +import { LoginExperience } from "@/components/auth/login-experience"; import { normalizeAuthNextPath } from "@/lib/auth/redirects"; export const dynamic = "force-dynamic"; @@ -18,184 +16,22 @@ export default async function LoginPage({ searchParams }: LoginPageProps = {}) { const nextPath = normalizeAuthNextPath(resolvedSearchParams?.next); return ( -
+
); } - -function MotionLabPreview() { - const frames = ["scale", "fade", "ease", "ship"]; - const exports = [ - { label: "React", value: "82%" }, - { label: "CSS", value: "64%" }, - { label: "Tokens", value: "74%" }, - ]; - - return ( -
-
-
-
-
- -
-
-

- motion lab -

-

- Convert motion references into shippable code. -

-
- -
-
-
- - - analysis.ts - -
-
-

$ motioncode analyze hover.mp4

-

> extracting 8 keyframes

-

> easing cubic-bezier locked

-

> reduced motion fallback

-

> export ready

-
-
- -
-

- frame sampler -

-
- {frames.map((frame, index) => ( -
- - - {index + 1} {frame} - -
- ))} -
-
- -
-

- curve solver -

-
- -
-
- -
-

- export stack -

-
- {exports.map((item) => ( -
- - {item.label} - - - - -
- ))} -
-
- -
-
-
-
- ); -} diff --git a/app/onboarding/page.tsx b/app/onboarding/page.tsx index 396701e..1abdfa7 100644 --- a/app/onboarding/page.tsx +++ b/app/onboarding/page.tsx @@ -1,5 +1,6 @@ import { AppShell } from "@/components/dashboard/app-shell"; import { OnboardingForm } from "@/components/dashboard/onboarding-form"; +import { getEntitlementSummary } from "@/lib/server/entitlements"; import { requireDashboardUser } from "../dashboard/data"; @@ -7,9 +8,14 @@ export const dynamic = "force-dynamic"; export default async function OnboardingPage() { const user = await requireDashboardUser("/onboarding", { paidOnly: true }); + const summary = await getEntitlementSummary(user.id); return ( - +

diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 5b97bd3..b097654 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,5 +1,4 @@ import Link from "next/link"; -import { Check, Sparkles } from "lucide-react"; import { SiteFooter, SiteHeader } from "@/components/marketing"; import { PLAN_ENTITLEMENTS, type PlanTier } from "@/lib/contracts/plans"; @@ -46,42 +45,37 @@ const FEATURE_LABELS: Array<[keyof typeof PLAN_ENTITLEMENTS.free, string]> = [ ["auditLogRetentionDays", "audit log days"], ]; +const TIERS: PlanTier[] = ["free", "pro", "studio"]; + export default function PricingPage() { return ( -

+
-
-
-
-

- {"// pricing"} -

-

+
+
+
+
Pricing
+

Access tiers for motion analysis.

-

- Start free, then upgrade through Razorpay when production work - needs more analyses, saved projects, or shared workspace access. -

- - Account - -

-
+

+ Start free, then upgrade through Razorpay when production work needs + more analyses, saved projects, or shared workspace access. +

+
-
-
-
- {(["free", "pro", "studio"] as const).map((tier) => ( - - ))} -
+
+ {TIERS.map((tier) => ( + + ))}
+ +

+ Prices in INR, billed monthly through Razorpay. Cancel anytime from + your account. +

@@ -89,85 +83,49 @@ export default function PricingPage() { ); } -function PlanColumn({ - tier, -}: { - tier: PlanTier; -}) { +function PlanColumn({ tier }: { tier: PlanTier }) { const copy = PLAN_COPY[tier]; const entitlements = PLAN_ENTITLEMENTS[tier]; - const isFeatured = tier === "studio"; + const isFeatured = tier === "pro"; return ( -
-
+
-

- {tier} -

-

- {tier} -

+

{tier}

+

{copy.description}

{isFeatured ? ( -
-

- {copy.description} -

-
- {copy.price} - {copy.period} -
-
- {tier === "free" ? ( - - {copy.cta} - - ) : ( -
- -
- )} + +
+ {copy.price} + {copy.period}
-
    + +
      {FEATURE_LABELS.map(([key, label]) => ( -
    • -
    • + {formatFeatureValue(entitlements[key])} {label}
    • ))} -
    • -
    • -
    • -
    • +
    • {entitlements.supportPriority} support
    -
+ + {tier === "free" ? ( + + {copy.cta} → + + ) : ( +
+ +
+ )} + ); } diff --git a/components/account/AccountContent.tsx b/components/account/AccountContent.tsx new file mode 100644 index 0000000..aa9b485 --- /dev/null +++ b/components/account/AccountContent.tsx @@ -0,0 +1,254 @@ +import Link from "next/link"; +import { + BarChart3, + CreditCard, + Download, + Shield, + Sparkles, + Trash2, + UserRound, +} from "lucide-react"; +import type { ReactNode } from "react"; + +import { + requestAccountDeletionAction, + requestDataExportAction, +} from "@/app/account/actions"; +import { getEntitlementSummary } from "@/lib/server/entitlements"; +import { getCurrentUser } from "@/lib/supabase/server"; + +type AccountContentProps = { + /** Optional flash notices (rendered above the metrics) for the full page. */ + notices?: ReactNode; +}; + +/** + * The body of the Account surface — metrics, profile, entitlements, billing, + * upgrade, and data sections. Rendered both by the standalone `/account` page + * (with its own page chrome) and by the `@modal` intercept (inside RouteModal), + * so the two views never drift apart. + */ +export async function AccountContent({ notices }: AccountContentProps = {}) { + const user = await getCurrentUser(); + if (!user) { + return ( +

+ Account settings are available after authentication. +

+ ); + } + + const summary = await getEntitlementSummary(user.id); + const profile = summary.profile; + const subscription = summary.subscription; + const paymentProvider = subscription?.payment_provider ?? null; + const subscriptionId = subscription?.razorpay_subscription_id; + const entitlementRows = [ + ["Daily analyses", summary.entitlements.dailyAnalyses.toLocaleString()], + [ + "Frames per analysis", + summary.entitlements.maxFramesPerAnalysis.toLocaleString(), + ], + [ + "Upload limit", + `${Math.round(summary.entitlements.maxUploadBytes / 1024 / 1024)} MB`, + ], + ["Saved projects", summary.entitlements.savedProjects.toLocaleString()], + ["Team seats", summary.entitlements.teamSeats.toLocaleString()], + [ + "Audit retention", + `${summary.entitlements.auditLogRetentionDays.toLocaleString()} days`, + ], + ]; + + return ( +
+ {notices} + +
+
+ +
+ + + +
+ +
+ + + + + +
+
+ ); +} + +function Metric({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} + + {label} + +
+

{value}

+
+ ); +} + +function Panel({ + children, + icon, + title, +}: { + children: ReactNode; + icon: ReactNode; + title: string; +}) { + return ( +
+
+ {icon} +

+ {title} +

+
+ {children} +
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function titleCase(value: string) { + return value + .split("_") + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat("en", { + dateStyle: "medium", + }).format(new Date(value)); +} diff --git a/components/app/AppShell.module.css b/components/app/AppShell.module.css index b4bb311..916b3af 100644 --- a/components/app/AppShell.module.css +++ b/components/app/AppShell.module.css @@ -5,8 +5,9 @@ --app-hairline: color-mix(in srgb, var(--text) 7%, transparent); position: relative; display: flex; - width: 100vw; - height: 100vh; + width: 100%; + height: 100%; + min-height: 100vh; overflow: hidden; flex-direction: column; background: diff --git a/components/app/AppShell.tsx b/components/app/AppShell.tsx index e642119..a9c7098 100644 --- a/components/app/AppShell.tsx +++ b/components/app/AppShell.tsx @@ -1,7 +1,5 @@ "use client"; -import { Boxes, FolderKanban, Gauge, UserCircle } from "lucide-react"; -import Link from "next/link"; import { useCallback, useEffect, @@ -10,12 +8,12 @@ import { type DragEvent, type MouseEvent, } from "react"; +import { useRouter } from "next/navigation"; import { AppStatusBar } from "@/components/app/AppStatusBar"; -import { CodeOutput } from "@/components/app/CodeOutput"; -import { MotionSpecPanel } from "@/components/app/MotionSpecPanel"; +import { FreeSaveNoticeModal } from "@/components/app/FreeSaveNoticeModal"; import { ProcessCanvas } from "@/components/app/ProcessCanvas"; -import { Scorecard } from "@/components/app/Scorecard"; +import { AnalyzeStudio } from "@/components/app/studio/AnalyzeStudio"; import { UploadPanel } from "@/components/app/UploadPanel"; import type { ApiResponse } from "@/lib/contracts/errors"; import type { AnalysisResult } from "@/lib/contracts/motion"; @@ -25,22 +23,22 @@ import { type PlanTier, } from "@/lib/contracts/plans"; import { extractFrames, isSupportedMediaFile } from "@/lib/extractFrames"; -import { - CODE_TABS, - type CodeTab, - getCodeContent, - getDownloadFilename, -} from "@/lib/generatedCode"; +import { CODE_TABS, type CodeTab } from "@/lib/generatedCode"; import type { MotionSpecEditableField } from "@/lib/motionSpecEditor"; import { updateAnalysisResultSpec } from "@/lib/motionSpecEditor"; import { incrementUsage, usagesLeft } from "@/lib/rateLimit"; import styles from "./AppShell.module.css"; -import type { AnalysisStage, ScoreKey } from "./types"; +import type { AnalysisStage } from "./types"; const DEFAULT_ENTITLEMENTS = PLAN_ENTITLEMENTS.free; const DEFAULT_FRAME_COUNT = DEFAULT_ENTITLEMENTS.maxFramesPerAnalysis; +// Free-tier "your work won't be saved" consent. Acknowledged once per session; +// the permanent key lets a user opt out of the reminder for good on this device. +const FREE_SAVE_ACK_SESSION_KEY = "motioncode_free_save_ack_session"; +const FREE_SAVE_ACK_DISMISSED_KEY = "motioncode_free_save_ack_dismissed"; + const INTENT_COLORS: Record = { entrance: "#9ef0c0", exit: "#f58f7c", @@ -57,12 +55,6 @@ const STATUS_MESSAGES = [ "Almost there...", ]; -const PRODUCT_NAV_ITEMS = [ - { href: "/dashboard", icon: Gauge, label: "Dashboard" }, - { href: "/projects", icon: FolderKanban, label: "Projects" }, - { href: "/workspaces", icon: Boxes, label: "Workspaces" }, -] as const; - type AppShellProps = { initialDailyAnalysisUsage?: { limit: number; @@ -78,14 +70,41 @@ export function AppShell({ initialEntitlements = DEFAULT_ENTITLEMENTS, initialPlanTier = DEFAULT_ENTITLEMENTS.tier, }: AppShellProps = {}) { + const router = useRouter(); const fileInputRef = useRef(null); const fileUrlRef = useRef(null); const stepTimerRef = useRef(null); const scannerTimerRef = useRef(null); const statusTimerRef = useRef(null); + // Plan tier and entitlements are owned by the server: the parent server + // component resolves them fresh from the database and passes them as props, so + // deriving them directly here means a server re-render (e.g. after an admin + // upgrade/downgrade) updates the UI, the default model, and plan gating. const userPlan: PlanTier = initialPlanTier; const entitlements = initialEntitlements; + // Free tier gets a read-only studio: preview + copy only. Paid tiers edit. + const editable = userPlan !== "free"; + + // Re-read entitlements from the server when the user returns to the tab so an + // open session reflects plan changes made elsewhere (admin override, billing). + // router.refresh() re-runs the server component, which re-resolves the plan + // from the database and feeds the new values back through the props above. + useEffect(() => { + const refreshOnFocus = () => { + if (document.visibilityState === "visible") { + router.refresh(); + } + }; + + window.addEventListener("focus", refreshOnFocus); + document.addEventListener("visibilitychange", refreshOnFocus); + + return () => { + window.removeEventListener("focus", refreshOnFocus); + document.removeEventListener("visibilitychange", refreshOnFocus); + }; + }, [router]); const [file, setFile] = useState(null); const [fileUrl, setFileUrl] = useState(null); @@ -97,13 +116,11 @@ export function AppShell({ const [stage, setStage] = useState("idle"); const [result, setResult] = useState(null); const [activeTab, setActiveTab] = useState("CSS"); - const [copied, setCopied] = useState(false); const [error, setError] = useState(null); const [validationError, setValidationError] = useState(null); const [flashError, setFlashError] = useState(false); const [dragActive, setDragActive] = useState(false); const [showToast, setShowToast] = useState(false); - const [hoveredScore, setHoveredScore] = useState(null); const [activeStep, setActiveStep] = useState(0); const [scannerIndex, setScannerIndex] = useState(0); const [statusBarMsgIndex, setStatusBarMsgIndex] = useState(0); @@ -111,6 +128,14 @@ export function AppShell({ const [serverUsageRemaining, setServerUsageRemaining] = useState< number | null >(initialDailyAnalysisUsage?.remaining ?? null); + // True once the free-tier save warning has been acknowledged — permanently + // (localStorage) or for the current session (sessionStorage). Resolved lazily + // so a returning user is never prompted twice. Nothing renders from this value + // before interaction, so reading storage during init causes no hydration drift. + const [freeSaveAcknowledged, setFreeSaveAcknowledged] = + useState(readFreeSaveAcknowledged); + const [freeSaveModalOpen, setFreeSaveModalOpen] = useState(false); + const pendingFileRef = useRef(null); const localUsageRemaining = usagesLeft(entitlements.dailyAnalyses); const usageRemaining = @@ -221,6 +246,49 @@ export function AppShell({ [frameCount, handleFileWithCount], ); + // Entry point for every upload (file picker + drag/drop). Free-tier users see + // the "won't be saved" notice once before their first analysis can proceed. + const requestFile = useCallback( + (selectedFile: File) => { + if (userPlan === "free" && !freeSaveAcknowledged) { + pendingFileRef.current = selectedFile; + setFreeSaveModalOpen(true); + return; + } + handleFile(selectedFile); + }, + [freeSaveAcknowledged, handleFile, userPlan], + ); + + const handleFreeSaveConfirm = useCallback( + (dontRemind: boolean) => { + try { + sessionStorage.setItem(FREE_SAVE_ACK_SESSION_KEY, "1"); + if (dontRemind) { + localStorage.setItem(FREE_SAVE_ACK_DISMISSED_KEY, "1"); + } + } catch { + // Best-effort persistence; proceeding regardless of storage failure. + } + setFreeSaveAcknowledged(true); + setFreeSaveModalOpen(false); + const queued = pendingFileRef.current; + pendingFileRef.current = null; + if (queued) { + handleFile(queued); + } + }, + [handleFile], + ); + + const handleFreeSaveCancel = useCallback(() => { + setFreeSaveModalOpen(false); + pendingFileRef.current = null; + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, []); + const handleRemoveFile = useCallback( (event?: MouseEvent) => { event?.stopPropagation(); @@ -318,32 +386,12 @@ export function AppShell({ setDragActive(false); const selectedFile = event.dataTransfer.files?.[0]; if (selectedFile) { - handleFile(selectedFile); + requestFile(selectedFile); } }, - [handleFile], + [requestFile], ); - const handleCopy = useCallback(() => { - const code = getCodeContent(result, activeTab); - void navigator.clipboard.writeText(code); - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); - }, [activeTab, result]); - - const handleDownload = useCallback(() => { - const code = getCodeContent(result, activeTab); - const blob = new Blob([code], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = getDownloadFilename(activeTab); - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); - }, [activeTab, result]); - const handleSpecChange = useCallback( (field: MotionSpecEditableField, value: unknown) => { setResult((current) => @@ -472,32 +520,6 @@ export function AppShell({ return (
- -

MotionCode animation converter

@@ -517,7 +539,7 @@ export function AppShell({ onDragLeave={onDragLeave} onDragOver={onDragOver} onDrop={onDrop} - onFileSelected={handleFile} + onFileSelected={requestFile} onFrameCountChange={updateFrameCount} onRemoveFile={handleRemoveFile} stage={stage} @@ -561,25 +583,16 @@ export function AppShell({ /> ) : (
- - -
)} @@ -598,6 +611,13 @@ export function AppShell({ Analysis complete - code ready
)} + +
); @@ -641,6 +661,21 @@ function getStoredTab(): CodeTab | null { return CODE_TABS.includes(savedTab as CodeTab) ? (savedTab as CodeTab) : null; } +function readFreeSaveAcknowledged(): boolean { + if (typeof window === "undefined") { + return false; + } + try { + return ( + localStorage.getItem(FREE_SAVE_ACK_DISMISSED_KEY) === "1" || + sessionStorage.getItem(FREE_SAVE_ACK_SESSION_KEY) === "1" + ); + } catch { + // Storage blocked (private mode); fail open so the notice still shows. + return false; + } +} + function formatMegabytes(bytes: number) { return Math.round(bytes / (1024 * 1024)); } diff --git a/components/app/FreeSaveNoticeModal.tsx b/components/app/FreeSaveNoticeModal.tsx new file mode 100644 index 0000000..f3e58cc --- /dev/null +++ b/components/app/FreeSaveNoticeModal.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { TriangleAlert } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +type FreeSaveNoticeModalProps = { + open: boolean; + /** Called when the user backs out — the pending upload is discarded. */ + onCancel: () => void; + /** Called when the user acknowledges. `dontRemind` persists the opt-out. */ + onConfirm: (dontRemind: boolean) => void; +}; + +export function FreeSaveNoticeModal({ + open, + onCancel, + onConfirm, +}: FreeSaveNoticeModalProps) { + const confirmRef = useRef(null); + const [dontRemind, setDontRemind] = useState(false); + + useEffect(() => { + if (!open) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onCancel(); + }; + document.addEventListener("keydown", onKeyDown); + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + const focusTimer = window.setTimeout(() => confirmRef.current?.focus(), 60); + + return () => { + document.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = previousOverflow; + window.clearTimeout(focusTimer); + }; + }, [open, onCancel]); + + if (!open || typeof document === "undefined") return null; + + return createPortal( +
+ + +
+ + , + document.body, + ); +} diff --git a/components/app/MotionSpecPanel.module.css b/components/app/MotionSpecPanel.module.css index 807c8f4..a6430b1 100644 --- a/components/app/MotionSpecPanel.module.css +++ b/components/app/MotionSpecPanel.module.css @@ -1,8 +1,13 @@ .panel { + container-type: inline-size; + container-name: spec; flex: 0 0 auto; border-bottom: 1px solid var(--border); background: rgba(17, 18, 13, 0.92); - padding: 14px clamp(16px, 2vw, 24px); + padding: 16px clamp(16px, 2vw, 24px); + + /* Legible warm label tone — replaces the too-dark #565449 muted. */ + --spec-label: color-mix(in srgb, var(--accent) 56%, transparent); } .topline { @@ -15,19 +20,21 @@ .summary { display: flex; min-width: 0; - align-items: center; - gap: 14px; + align-items: flex-start; + gap: 12px; } .intentBadge { flex: 0 0 auto; + margin-top: 2px; border: 1px solid var(--intent-color); border-radius: 6px; background: color-mix(in srgb, var(--intent-color) 13%, transparent); color: var(--intent-color); font-family: var(--font-mono); - font-size: 0.7rem; - padding: 5px 10px; + font-size: 0.66rem; + letter-spacing: 0.08em; + padding: 5px 9px; text-transform: uppercase; } @@ -36,26 +43,30 @@ } .summaryText h2 { - margin: 0 0 3px; + margin: 0 0 4px; color: var(--text); - font-size: 0.86rem; - letter-spacing: 0; + font-family: var(--font-body); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.01em; } .summaryText p { - max-width: 72ch; + display: -webkit-box; + max-width: 64ch; margin: 0; overflow: hidden; - color: var(--muted); - font-size: 0.78rem; - line-height: 1.45; - text-overflow: ellipsis; - white-space: nowrap; + color: var(--accent); + font-family: var(--font-body); + font-size: 0.82rem; + line-height: 1.5; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .resetButton { display: inline-flex; - min-height: 38px; + min-height: 36px; flex: 0 0 auto; align-items: center; gap: 7px; @@ -65,32 +76,55 @@ color: var(--accent); font-family: var(--font-mono); font-size: 0.72rem; - padding: 8px 11px; + padding: 8px 12px; + transition: + border-color 160ms ease, + background 160ms ease, + color 160ms ease; +} + +.resetButton:hover { + border-color: var(--accent-border); + background: rgba(216, 207, 188, 0.08); + color: var(--text); +} + +.resetButton:focus-visible { + outline: 2px solid var(--accent-border); + outline-offset: 2px; } .grid { display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px; - margin-top: 14px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px 14px; + margin-top: 16px; +} + +/* Longer values get the full width so they never get clipped. */ +.fieldElement, +.fieldEasing { + grid-column: 1 / -1; } .field { display: flex; min-width: 0; flex-direction: column; - gap: 5px; + gap: 6px; } .fieldWide { - margin-top: 10px; + grid-column: 1 / -1; + margin-top: 2px; } .field > span { - color: var(--muted); + color: var(--spec-label); font-family: var(--font-mono); - font-size: 0.64rem; - letter-spacing: 0.12em; + font-size: 0.62rem; + font-weight: 500; + letter-spacing: 0.14em; text-transform: uppercase; } @@ -99,38 +133,83 @@ .loopToggle { width: 100%; min-width: 0; - min-height: 34px; + min-height: 36px; border: 1px solid var(--border); border-radius: 6px; background: rgba(0, 0, 0, 0.22); color: var(--text); font-family: var(--font-mono); - font-size: 0.72rem; - padding: 7px 8px; + font-size: 0.8rem; + padding: 8px 9px; + transition: + border-color 160ms ease, + background 160ms ease; +} + +.field input:hover, +.field select:hover { + border-color: var(--accent-border); +} + +.field input:focus-visible, +.field select:focus-visible { + border-color: var(--accent); + outline: none; +} + +/* Read-only (free tier) values: readable, never truncated — they wrap. */ +.readOnlyValue { + display: block; + width: 100%; + min-width: 0; + min-height: 36px; + border: 1px solid transparent; + border-radius: 6px; + background: rgba(0, 0, 0, 0.16); + color: var(--text); + font-family: var(--font-mono); + font-size: 0.8rem; + line-height: 1.4; + padding: 8px 9px; + overflow-wrap: anywhere; +} + +/* Description is prose — set it in the readable sans face, sentence case. */ +.fieldWide input, +.fieldWide .readOnlyValue { + font-family: var(--font-body); + font-size: 0.83rem; + line-height: 1.5; } .loopToggle { display: flex; align-items: center; gap: 8px; + cursor: pointer; } .loopToggle input { width: auto; min-height: 0; padding: 0; + accent-color: var(--accent); } -@media (max-width: 1100px) { +@container spec (max-width: 360px) { .grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: 1fr; + } + + .fieldElement, + .fieldEasing { + grid-column: auto; } } +/* Drawer goes full-width on phones; relax the summary into a column. */ @media (max-width: 640px) { - .topline, - .summary { - align-items: flex-start; + .topline { flex-direction: column; } @@ -138,12 +217,4 @@ width: 100%; justify-content: center; } - - .grid { - grid-template-columns: 1fr; - } - - .summaryText p { - white-space: normal; - } } diff --git a/components/app/MotionSpecPanel.tsx b/components/app/MotionSpecPanel.tsx index 8689a8e..e02c849 100644 --- a/components/app/MotionSpecPanel.tsx +++ b/components/app/MotionSpecPanel.tsx @@ -8,6 +8,8 @@ import styles from "./MotionSpecPanel.module.css"; type MotionSpecPanelProps = { intentColor: string; + /** When false (free tier) fields render as static, read-only values. */ + editable?: boolean; onReset: () => void; onSpecChange: (field: MotionSpecEditableField, value: unknown) => void; result: AnalysisResult; @@ -26,6 +28,7 @@ const INTENTS: MotionIntent[] = [ export function MotionSpecPanel({ intentColor, + editable = true, onReset, onSpecChange, result, @@ -59,76 +62,115 @@ export function MotionSpecPanel({
- + {editable ? ( + + ) : ( + + )} - - onSpecChange("element", event.target.value)} - value={spec.element} - /> + + {editable ? ( + onSpecChange("element", event.target.value)} + value={spec.element} + /> + ) : ( + + )} - onSpecChange("durationMs", event.target.value)} - type="number" - value={spec.durationMs} - /> + {editable ? ( + onSpecChange("durationMs", event.target.value)} + type="number" + value={spec.durationMs} + /> + ) : ( + + )} - onSpecChange("delayMs", event.target.value)} - type="number" - value={spec.delayMs} - /> - - - onSpecChange("easing", event.target.value)} - value={spec.easing} - /> + {editable ? ( + onSpecChange("delayMs", event.target.value)} + type="number" + value={spec.delayMs} + /> + ) : ( + + )} - - + ) : ( + + )} + + + {editable ? ( + + ) : ( + + )}
- onSpecChange("description", event.target.value)} - value={spec.description} - /> + {editable ? ( + onSpecChange("description", event.target.value)} + value={spec.description} + /> + ) : ( + + )} ); } +function ReadOnlyValue({ value }: { value: string }) { + return ( + + {value} + + ); +} + type FieldProps = { children: ReactNode; + className?: string; label: string; wide?: boolean; }; -function Field({ children, label, wide = false }: FieldProps) { +function Field({ children, className = "", label, wide = false }: FieldProps) { return ( -