Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
uses: pnpm/action-setup@b0f76dfb45f55f8421693e4803ac7bb65143bd34 # v6

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 22
cache: pnpm
Expand Down Expand Up @@ -90,7 +90,7 @@ jobs:
uses: pnpm/action-setup@b0f76dfb45f55f8421693e4803ac7bb65143bd34 # v6

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 22
cache: pnpm
Expand Down
13 changes: 9 additions & 4 deletions app/[locale]/demo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default async function DemoPage({ params }: Props) {
const path = getUnfreezePath("cyber_upi_chain", locale);

return (
<div className="mx-auto flex max-w-[430px] flex-col gap-4">
<div className="mx-auto flex max-w-[430px] flex-col gap-4 lg:max-w-2xl">
{/* Lead with the killer insight in plain words, not a caveat — all three
comprehension reviewers (HRs + a normal user) said the value was buried
under a legal briefing. State it first; let the cards below be the proof. */}
Expand Down Expand Up @@ -137,8 +137,13 @@ export default async function DemoPage({ params }: Props) {
frozenAmountInr="1,800"
/>

<section className="u-card p-4">
<p className="type-eyebrow">{t("sourcesTitle")}</p>
{/* Reference material — collapsed by default so the demo leads with the
usable output instead of a wall of legal text. All content preserved. */}
<details className="u-card p-4">
<summary className="type-eyebrow flex cursor-pointer list-none items-center justify-between">
{t("sourcesTitle")}
<span aria-hidden="true" className="text-[var(--ink-faint)]">+</span>
</summary>
<p className="mt-2 text-[0.8125rem] leading-relaxed text-[var(--ink-muted)]">
{t("sourcesDesc")}
</p>
Expand All @@ -152,7 +157,7 @@ export default async function DemoPage({ params }: Props) {
<div className="mt-3">
<LegalPositionNote position={DISPUTED_AMOUNT_RULE} />
</div>
</section>
</details>

<section className="u-hero animate-fade-up px-5 py-6">
<div className="relative z-[1]">
Expand Down
96 changes: 96 additions & 0 deletions app/[locale]/impact/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { StatCard } from "@/components/metrics/StatCard";
import { getPublicStats, type PublicStats } from "@/lib/metrics/public-stats";

type Props = { params: Promise<{ locale: string }> };

// Always render fresh so the counts are live (and never cache a build-time
// empty state). The page issues four cheap COUNT queries — negligible load.
export const dynamic = "force-dynamic";

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "ImpactPage" });
return { title: t("title"), description: t("intro") };
}

export default async function ImpactPage({ params }: Props) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("ImpactPage");

let stats: PublicStats | null = null;
try {
stats = await getPublicStats();
} catch {
stats = null;
}

const fmt = (n: number) => n.toLocaleString("en-IN");

return (
<div className="mx-auto flex max-w-[430px] flex-col gap-4 lg:max-w-3xl">
<div className="animate-fade-up">
<p className="type-eyebrow">{t("eyebrow")}</p>
<h1 className="type-display-xl mt-1.5 text-[1.75rem] leading-tight">
{t("title")}
</h1>
<p className="mt-2.5 text-[0.9375rem] leading-relaxed text-[var(--ink-muted)]">
{t("intro")}
</p>
</div>

{!stats || stats.casesStarted === 0 ? (
<section className="u-card animate-fade-up p-5">
<p className="type-display text-[1.05rem]">{t("emptyTitle")}</p>
<p className="mt-1.5 text-[0.875rem] leading-relaxed text-[var(--ink-muted)]">
{t("emptyBody")}
</p>
<Link
href="/start"
className="u-btn u-btn-primary mt-4 flex min-h-[48px] w-full text-base font-semibold"
>
{t("startCta")}
</Link>
</section>
) : (
<>
<section className="grid grid-cols-2 gap-3">
<StatCard label={t("usersLabel")} value={fmt(stats.users)} />
<StatCard label={t("casesLabel")} value={fmt(stats.casesStarted)} />
<StatCard
label={t("lettersGeneratedLabel")}
value={fmt(stats.lettersGenerated)}
/>
<StatCard
label={t("lettersSentLabel")}
value={fmt(stats.lettersSent)}
/>
<StatCard
label={t("unfrozenLabel")}
value={t("unfrozenSoon")}
sub={t("unfrozenNote")}
muted
/>
</section>
<p className="type-caption text-[var(--ink-faint)]">
{t("updatedPrefix")}{" "}
{new Date(stats.generatedAt).toLocaleString("en-IN")}
</p>
<Link
href="/start"
className="u-btn u-btn-primary mt-1 flex min-h-[48px] w-full text-base font-semibold lg:w-auto lg:self-start lg:px-6"
>
{t("startCta")}
</Link>
</>
)}

<p className="mt-1 text-[0.6875rem] leading-relaxed text-[var(--ink-faint)]">
{t("disclaimer")}
</p>
</div>
);
}
2 changes: 2 additions & 0 deletions app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { NextIntlClientProvider, hasLocale } from "next-intl";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import "../globals.css";
import { Analytics } from "@vercel/analytics/next";
import { SiteHeader } from "@/components/layout/SiteHeader";
import { BottomTabBar } from "@/components/layout/BottomTabBar";
import { Link } from "@/i18n/navigation";
Expand Down Expand Up @@ -204,6 +205,7 @@ export default async function LocaleLayout({
</p>
</div>
</footer>
<Analytics />
</NextIntlClientProvider>
</body>
</html>
Expand Down
49 changes: 48 additions & 1 deletion app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { JsonLd } from "@/components/seo/JsonLd";
import { TrackView } from "@/components/analytics/TrackView";

type Props = { params: Promise<{ locale: string }> };

Expand Down Expand Up @@ -111,8 +112,10 @@ export default async function HomePage({ params }: Props) {
];

return (
<div className="mx-auto flex max-w-[430px] flex-col gap-4">
<div className="mx-auto w-full max-w-[430px] lg:max-w-5xl">
<JsonLd data={jsonLd} />
<TrackView event="landing_view" />
<div className="flex flex-col gap-4 lg:grid lg:grid-cols-2 lg:items-start lg:gap-8">
<section className="u-hero animate-fade-up px-5 py-6">
<div className="relative z-[1]">
<p className="type-eyebrow">{t("eyebrow")}</p>
Expand Down Expand Up @@ -155,6 +158,49 @@ export default async function HomePage({ params }: Props) {
</div>
</section>

{/* Product preview (desktop): show the plan + a draft-letter excerpt so a
visitor sees the output before starting. Hidden on mobile, where the
worked-example block below already fills this role. */}
<aside className="hidden animate-fade-up stagger-1 lg:block">
<div className="u-card p-5">
<p className="type-eyebrow">{t("previewLabel")}</p>
<div className="mt-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface)] p-3">
<p className="text-[0.8125rem] font-bold text-[var(--ink)]">
{t("previewPlanTitle")}
</p>
<ol className="mt-2 flex flex-col gap-1.5">
{steps.map((step, i) => (
<li
key={i}
className="flex items-center gap-2 text-[0.78125rem] text-[var(--ink-muted)]"
>
<span className="flex h-5 w-5 flex-none items-center justify-center rounded-full bg-[var(--color-sky-mist)] text-[0.6875rem] font-bold text-[var(--color-sky-deep)]">
{i + 1}
</span>
{step.title}
</li>
))}
</ol>
</div>
<div className="mt-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface)] p-3">
<p className="text-[0.8125rem] font-bold text-[var(--ink)]">
{t("previewLetterTitle")}
</p>
<p className="type-mono-data mt-1.5 text-[0.75rem] leading-relaxed text-[var(--ink-faint)]">
{t("previewLetterExcerpt")}
</p>
</div>
<Link
href="/demo"
className="mt-3 inline-block text-[0.8125rem] font-semibold text-[var(--color-sky-deep)] no-underline"
>
{t("demoTitle")} →
</Link>
</div>
</aside>
</div>

<div className="mt-4 flex flex-col gap-4 lg:mx-auto lg:mt-8 lg:max-w-2xl">
{/* The one loud block on the page: a reviewer/judge/curious visitor has no
case or papers — this is the only thing they can click. Solid brand blue
so it can't be scrolled past, placed in the first viewport. */}
Expand Down Expand Up @@ -264,6 +310,7 @@ export default async function HomePage({ params }: Props) {
>
{t("rightsLink")}
</Link>
</div>
</div>
);
}
3 changes: 3 additions & 0 deletions app/[locale]/start/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from '@/i18n/navigation';
import { ConsentScreen } from '@/components/legal/ConsentScreen';
import { IntakeWizard, type IntakeWizardResult } from '@/components/intake/IntakeWizard';
import { SAVE_CASE_STORAGE_KEY } from '@/lib/case/save-case-storage';
import { track } from '@/lib/analytics/events';

/**
* The single intake entry point: consent (blocks B + F) → 5-question wizard →
Expand Down Expand Up @@ -70,6 +71,7 @@ export default function StartPage() {
// sessionStorage full / private mode — save page still shows public_id if present
}

track('intake_completed');
router.push(`/cases/${json.id}/save`);
} catch (err) {
setError(
Expand All @@ -89,6 +91,7 @@ export default function StartPage() {
onContinue={(aiConsentAccepted) => {
setAiConsent(aiConsentAccepted);
setPhase('questions');
track('case_started');
}}
/>
) : (
Expand Down
17 changes: 17 additions & 0 deletions components/analytics/TrackView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client';

import { useEffect } from 'react';
import { track, type FunnelEvent } from '@/lib/analytics/events';

/**
* Fires a single funnel event once on mount, then renders nothing. Lets a
* server component (e.g. the home page) record a view/step event without
* becoming a client component itself.
*/
export function TrackView({ event }: { event: FunnelEvent }) {
useEffect(() => {
track(event);
}, [event]);

return null;
}
2 changes: 2 additions & 0 deletions components/case/DraftLetterButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2, FilePlus2 } from 'lucide-react';
import { track } from '@/lib/analytics/events';

interface DraftLetterButtonProps {
caseId: string;
Expand Down Expand Up @@ -33,6 +34,7 @@ export function DraftLetterButton({ caseId, level }: DraftLetterButtonProps) {
throw new Error(json.error?.message ?? json.error ?? 'Could not start your letter');
}
setPhase('requested');
track('letter_generated', { level });
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
Expand Down
2 changes: 2 additions & 0 deletions components/escalations/MarkSentForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/ui/cn';
import { computeSha256HexInBrowser } from '@/lib/evidence/sha256';
import { validateUploadFile } from '@/lib/evidence/validate-file';
import { track } from '@/lib/analytics/events';

type MarkSentFormProps = {
caseId: string;
Expand Down Expand Up @@ -121,6 +122,7 @@ export function MarkSentForm({
}

setSuccess(true);
track('letter_marked_sent', { level });
onSuccess?.();
} catch (err) {
setError(err instanceof Error ? err.message : t('networkError'));
Expand Down
3 changes: 1 addition & 2 deletions components/layout/BottomTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,13 @@ export function BottomTabBar() {
return (
<nav
aria-label="Main"
className="sm:hidden"
className="flex sm:hidden"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 50,
display: 'flex',
background: '#fff',
borderTop: '1px solid rgba(20,30,40,.08)',
padding: '9px 20px 22px',
Expand Down
30 changes: 29 additions & 1 deletion components/legal/ConsentScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,35 @@ export function ConsentScreen({ onContinue, onBack }: ConsentScreenProps) {
<h1 className="type-display text-[1.375rem]">{t('title')}</h1>
</div>

<p className="mt-4 text-[0.84375rem] leading-relaxed text-[var(--ink-muted)]">{t('whatToExpect')}</p>
{/* Progress framing: the consent screen is step 1 of a short journey,
not a standalone legal wall. */}
<ol
className="mt-4 flex flex-wrap items-center gap-x-2 gap-y-1 text-[0.6875rem] font-semibold text-[var(--ink-faint)]"
aria-label={t('stepNote')}
>
<li className="flex items-center gap-1.5 text-[var(--color-sky-deep)]">
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-[var(--color-sky-deep)] text-[0.625rem] font-bold text-white">
1
</span>
{t('flowConsent')}
</li>
<span aria-hidden="true">›</span>
<li className="flex items-center gap-1.5">
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-[var(--border-strong)] text-[0.625rem] font-bold">
2
</span>
{t('flowQuestions')}
</li>
<span aria-hidden="true">›</span>
<li className="flex items-center gap-1.5">
<span className="flex h-4 w-4 items-center justify-center rounded-full border border-[var(--border-strong)] text-[0.625rem] font-bold">
3
</span>
{t('flowPlan')}
</li>
</ol>

<p className="mt-3 text-[0.84375rem] leading-relaxed text-[var(--ink-muted)]">{t('whatToExpect')}</p>
<details className="mt-3 rounded-[var(--radius-md)] border border-[var(--border)] bg-[var(--surface-raised)] px-3 py-2">
<summary className="cursor-pointer text-xs font-semibold text-[var(--color-sky-deep)]">
{t('readDataDetails')}
Expand Down
2 changes: 2 additions & 0 deletions components/letters/RequestDraft.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
import { Loader2 } from 'lucide-react';
import { DraftPendingRefresh } from '@/components/letters/DraftPendingRefresh';
import { track } from '@/lib/analytics/events';

type RequestDraftProps = {
caseId: string;
Expand Down Expand Up @@ -38,6 +39,7 @@ export function RequestDraft({ caseId, level }: RequestDraftProps) {
throw new Error(json.error?.message ?? t('errorFallback'));
}
setPhase('pending');
track('letter_generated', { level });
} catch (err) {
setError(err instanceof Error ? err.message : t('errorFallback'));
setPhase('error');
Expand Down
Loading