Claude/review background work r7 r9 w #3917
61 changed files+4937−825
Modified.env.example+18−0View fileUnifiedSplit
@@ -13,6 +13,24 @@ ADMIN_PASSWORD="changeme123"
1313# Anthropic Claude API (Marco's brain)
1414ANTHROPIC_API_KEY="sk-ant-..."
1515
16# OpenAI Whisper — for Marco Reid Voice dictation
17OPENAI_API_KEY="sk-..."
18
1619# Legal data sources
1720COURTLISTENER_API_KEY="your-courtlistener-api-key"
1821GOVINFO_API_KEY="your-govinfo-api-key"
22
23# Stripe — get keys from dashboard.stripe.com
24STRIPE_SECRET_KEY="sk_test_..."
25STRIPE_PUBLISHABLE_KEY="pk_test_..."
26STRIPE_WEBHOOK_SECRET="whsec_..."
27STRIPE_PRICE_LEGAL_STARTER="price_..."
28STRIPE_PRICE_LEGAL_PROFESSIONAL="price_..."
29STRIPE_PRICE_LEGAL_FIRM="price_..."
30STRIPE_PRICE_ACCOUNTING_STARTER="price_..."
31STRIPE_PRICE_ACCOUNTING_PROFESSIONAL="price_..."
32STRIPE_PRICE_ACCOUNTING_FIRM="price_..."
33STRIPE_PRICE_MARCO_CROSSDOMAIN="price_..."
34STRIPE_PRICE_MARCO_ENTERPRISE="price_..."
35# Stripe Connect (marketplace)
36STRIPE_CONNECT_CLIENT_ID="ca_..."
Modified.github/dependabot.yml+13−0View fileUnifiedSplit
@@ -19,6 +19,19 @@ updates:
1919 prefix: "deps:"
2020 # Group minor and patch updates together to reduce PR noise
2121 groups:
22 # React ecosystem must always upgrade together — @types/react-dom@19
23 # requires @types/react@19, so splitting them into separate PRs causes
24 # peer dependency conflicts that break every quality gate job.
25 react-ecosystem:
26 patterns:
27 - "react"
28 - "react-dom"
29 - "@types/react"
30 - "@types/react-dom"
31 update-types:
32 - "major"
33 - "minor"
34 - "patch"
2235 minor-and-patch:
2336 update-types:
2437 - "minor"
Modified.github/workflows/security-quality.yml+4−0View fileUnifiedSplit
@@ -59,6 +59,10 @@ jobs:
5959 name: Dependency Review
6060 runs-on: ubuntu-latest
6161 if: github.event_name == 'pull_request'
62 # This job requires GitHub Dependency Graph (GitHub Advanced Security).
63 # continue-on-error prevents the entire workflow from going red when the
64 # feature is not available on this repository.
65 continue-on-error: true
6266 steps:
6367 - uses: actions/checkout@v4
6468 - uses: actions/dependency-review-action@v4
Modified.gitignore+3−0View fileUnifiedSplit
@@ -37,3 +37,6 @@ yarn-error.log*
3737next-env.d.ts
3838
3939/lib/generated/prisma
40
41# Claude Code internal (worktrees, sessions)
42.claude/
ModifiedClaude.MD+691−691View fileUnifiedSplit
Large file (3,623 lines). Load full file
Addedapp/(marketing)/courts/bench/page.tsx+68−0View fileUnifiedSplit
@@ -0,0 +1,68 @@
1import type { Metadata } from "next";
2import Container from "@/app/components/shared/Container";
3import Button from "@/app/components/shared/Button";
4import Reveal from "@/app/components/effects/Reveal";
5
6export const metadata: Metadata = {
7 title: "Marco Reid Bench \u2014 Marco AI for Judges",
8 description:
9 "Verified case law and statute research, opinion drafting assistance, sentencing aids. Marco at the judge's elbow, never hallucinating.",
10};
11
12export default function BenchPage() {
13 return (
14 <>
15 <section className="relative flex min-h-[80vh] items-center justify-center">
16 <Container className="text-center">
17 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
18 Marco Reid Bench
19 </p>
20 <h1 className="mt-8 text-hero font-serif">
21 <span className="text-forest-500">Research and draft</span>
22 <br />
23 <span className="text-navy-700">from the bench.</span>
24 </h1>
25 <p className="mx-auto mt-8 max-w-2xl text-xl leading-relaxed text-navy-400">
26 Judges write opinions on Word with no research integration. Clerks scramble between
27 Westlaw, Lexis, and the court library. Marco Reid Bench puts verified case law,
28 statute, and policy research one keystroke away — with citation verification
29 so nothing reaches the page that didn’t come from a real source.
30 </p>
31 <div className="mt-12 flex justify-center gap-4">
32 <Button href="/courts/pilot" size="lg">Request a pilot</Button>
33 <Button href="/courts" variant="secondary" size="lg">Back to Courts</Button>
34 </div>
35 </Container>
36 </section>
37
38 <section className="py-32" aria-label="Features">
39 <Container>
40 <Reveal>
41 <h2 className="text-center text-display font-serif text-navy-700">
42 What it does.
43 </h2>
44 </Reveal>
45 <div className="mx-auto mt-16 grid max-w-3xl gap-3 sm:grid-cols-2">
46 {[
47 { title: "Verified research", desc: "Case law, statute, regulation — every citation checked" },
48 { title: "Opinion drafting", desc: "Bench memos and draft opinions in chambers' voice" },
49 { title: "Sentencing aids", desc: "Guidelines lookup, comparable case ranges" },
50 { title: "Bench book", desc: "Personalised checklists for routine motions" },
51 { title: "Confidential by design", desc: "Sovereign deployment, zero training on chambers data" },
52 { title: "Marco at the bench", desc: "Live research during hearings" },
53 { title: "Multi-jurisdictional", desc: "Federal, state, foreign and international law" },
54 { title: "Citation graph", desc: "Subsequent treatment, overruling, distinguishing" },
55 ].map((f) => (
56 <Reveal key={f.title} delay={0.05}>
57 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
58 <p className="font-semibold text-navy-700">{f.title}</p>
59 <p className="mt-2 text-sm text-navy-400">{f.desc}</p>
60 </div>
61 </Reveal>
62 ))}
63 </div>
64 </Container>
65 </section>
66 </>
67 );
68}
Addedapp/(marketing)/courts/docket/page.tsx+68−0View fileUnifiedSplit
@@ -0,0 +1,68 @@
1import type { Metadata } from "next";
2import Container from "@/app/components/shared/Container";
3import Button from "@/app/components/shared/Button";
4import Reveal from "@/app/components/effects/Reveal";
5
6export const metadata: Metadata = {
7 title: "Marco Reid Docket \u2014 AI Scheduling for Courts",
8 description:
9 "Judicial calendars, conflict detection, jury management, interpreter coordination, and continuance tracking. The docket runs itself.",
10};
11
12export default function DocketPage() {
13 return (
14 <>
15 <section className="relative flex min-h-[80vh] items-center justify-center">
16 <Container className="text-center">
17 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
18 Marco Reid Docket
19 </p>
20 <h1 className="mt-8 text-hero font-serif">
21 <span className="text-forest-500">The docket</span>
22 <br />
23 <span className="text-navy-700">runs itself.</span>
24 </h1>
25 <p className="mx-auto mt-8 max-w-2xl text-xl leading-relaxed text-navy-400">
26 Judges’ calendars are run on whiteboards and Outlook. Continuances cascade.
27 Interpreters double-book. Jury pools no-show. Marco Reid Docket replaces every
28 scheduling tool a court owns with one intelligent calendar that detects conflicts
29 before they happen.
30 </p>
31 <div className="mt-12 flex justify-center gap-4">
32 <Button href="/courts/pilot" size="lg">Request a pilot</Button>
33 <Button href="/courts" variant="secondary" size="lg">Back to Courts</Button>
34 </div>
35 </Container>
36 </section>
37
38 <section className="py-32" aria-label="Features">
39 <Container>
40 <Reveal>
41 <h2 className="text-center text-display font-serif text-navy-700">
42 What it does.
43 </h2>
44 </Reveal>
45 <div className="mx-auto mt-16 grid max-w-3xl gap-3 sm:grid-cols-2">
46 {[
47 { title: "Judge calendars", desc: "Bench schedules across every division" },
48 { title: "Conflict detection", desc: "Counsel availability, witness conflicts, holidays" },
49 { title: "Continuance tracking", desc: "Cascading reschedules across linked matters" },
50 { title: "Jury management", desc: "Summons, voir dire, payment, no-show flags" },
51 { title: "Interpreter booking", desc: "Auto-match by language and certification" },
52 { title: "Courtroom assignment", desc: "Optimised by case type and capacity" },
53 { title: "Docket publication", desc: "Public-facing daily docket, auto-updated" },
54 { title: "SMS reminders", desc: "Defendants, jurors, witnesses — multi-language" },
55 ].map((f) => (
56 <Reveal key={f.title} delay={0.05}>
57 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
58 <p className="font-semibold text-navy-700">{f.title}</p>
59 <p className="mt-2 text-sm text-navy-400">{f.desc}</p>
60 </div>
61 </Reveal>
62 ))}
63 </div>
64 </Container>
65 </section>
66 </>
67 );
68}
Addedapp/(marketing)/courts/filings/page.tsx+68−0View fileUnifiedSplit
@@ -0,0 +1,68 @@
1import type { Metadata } from "next";
2import Container from "@/app/components/shared/Container";
3import Button from "@/app/components/shared/Button";
4import Reveal from "@/app/components/effects/Reveal";
5
6export const metadata: Metadata = {
7 title: "Marco Reid Filings \u2014 E-Filing Reimagined",
8 description:
9 "A sane wrapper over PACER, CM/ECF, CE-File and friends. AI form-filling for self-represented litigants. Fewer rejections, faster processing.",
10};
11
12export default function FilingsPage() {
13 return (
14 <>
15 <section className="relative flex min-h-[80vh] items-center justify-center">
16 <Container className="text-center">
17 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
18 Marco Reid Filings
19 </p>
20 <h1 className="mt-8 text-hero font-serif">
21 <span className="text-forest-500">E-filing</span>
22 <br />
23 <span className="text-navy-700">that doesn’t make people cry.</span>
24 </h1>
25 <p className="mx-auto mt-8 max-w-2xl text-xl leading-relaxed text-navy-400">
26 More than 70% of civil cases have at least one self-represented litigant.
27 PACER, CM/ECF, CE-File, eCourts — every system was built for lawyers and
28 still defeats them. Marco Reid Filings is a clean wrapper with AI form-filling
29 for pro se litigants and an API for everyone else.
30 </p>
31 <div className="mt-12 flex justify-center gap-4">
32 <Button href="/courts/pilot" size="lg">Request a pilot</Button>
33 <Button href="/courts" variant="secondary" size="lg">Back to Courts</Button>
34 </div>
35 </Container>
36 </section>
37
38 <section className="py-32" aria-label="Features">
39 <Container>
40 <Reveal>
41 <h2 className="text-center text-display font-serif text-navy-700">
42 What it does.
43 </h2>
44 </Reveal>
45 <div className="mx-auto mt-16 grid max-w-3xl gap-3 sm:grid-cols-2">
46 {[
47 { title: "Pro se intake wizard", desc: "Plain-language guided filings in 100+ languages" },
48 { title: "Existing system bridge", desc: "PACER, CM/ECF, CE-File, Tyler Odyssey, eCourts" },
49 { title: "AI form auto-fill", desc: "From intake answers to court-ready PDFs" },
50 { title: "Pre-submission validation", desc: "Catch errors before the clerk does" },
51 { title: "Fee waiver flow", desc: "IFP applications drafted automatically" },
52 { title: "Service of process", desc: "Track and certify delivery" },
53 { title: "Document assembly", desc: "Exhibits, declarations, proposed orders" },
54 { title: "Open API", desc: "For practice management vendors and clinics" },
55 ].map((f) => (
56 <Reveal key={f.title} delay={0.05}>
57 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
58 <p className="font-semibold text-navy-700">{f.title}</p>
59 <p className="mt-2 text-sm text-navy-400">{f.desc}</p>
60 </div>
61 </Reveal>
62 ))}
63 </div>
64 </Container>
65 </section>
66 </>
67 );
68}
Addedapp/(marketing)/courts/page.tsx+227−0View fileUnifiedSplit
@@ -0,0 +1,227 @@
1import type { Metadata } from "next";
2import Link from "next/link";
3import { BRAND } from "@/lib/constants";
4import Container from "@/app/components/shared/Container";
5import Button from "@/app/components/shared/Button";
6import SchemaMarkup from "@/app/components/shared/SchemaMarkup";
7import AiDisclaimer from "@/app/components/shared/AiDisclaimer";
8import AnimatedCounter from "@/app/components/effects/AnimatedCounter";
9import Reveal from "@/app/components/effects/Reveal";
10
11export const metadata: Metadata = {
12 title: "Marco Reid Courts \u2014 AI Infrastructure for Every Courtroom on Earth",
13 description:
14 "Real-time transcription, docket management, e-filing, judicial research, and public transparency. The complete AI platform for courts. Built for every jurisdiction.",
15};
16
17const schema = {
18 "@context": "https://schema.org",
19 "@type": "SoftwareApplication",
20 name: "Marco Reid Courts",
21 applicationCategory: "GovernmentApplication",
22 operatingSystem: "Web",
23 description:
24 "Five-product AI suite for courts: real-time transcription, docket management, e-filing, judicial research and opinion drafting, and public transparency.",
25 url: `${BRAND.url}/courts`,
26};
27
28const products = [
29 {
30 href: "/courts/reporter",
31 name: "Marco Reid Reporter",
32 tag: "Real-time transcription",
33 headline: "End the court reporter shortage.",
34 body: "Real-time AI transcription with speaker diarisation, legal terminology, and certified output. Every word, every hearing, every language.",
35 },
36 {
37 href: "/courts/docket",
38 name: "Marco Reid Docket",
39 tag: "Scheduling & calendaring",
40 headline: "The docket runs itself.",
41 body: "Judicial calendars, conflict detection, jury management, interpreter coordination, and continuance tracking. Whiteboards retired.",
42 },
43 {
44 href: "/courts/filings",
45 name: "Marco Reid Filings",
46 tag: "E-filing reimagined",
47 headline: "Filings that don't make people cry.",
48 body: "A sane wrapper over PACER, CM/ECF, CE-File and friends. AI form-filling for self-represented litigants. Fewer rejections, faster processing.",
49 },
50 {
51 href: "/courts/bench",
52 name: "Marco Reid Bench",
53 tag: "Marco for judges",
54 headline: "Research and draft from the bench.",
55 body: "Verified case law, statute, and policy research. Opinion drafting assistance. Sentencing aids. Marco at the judge's elbow, never hallucinating.",
56 },
57 {
58 href: "/courts/public",
59 name: "Marco Reid Public",
60 tag: "Transparency & access",
61 headline: "Open justice, finally open.",
62 body: "Livestreaming, searchable transcripts, opinion publication, public docket access. The transparency every constitution promises and few courts deliver.",
63 },
64];
65
66export default function CourtsPage() {
67 return (
68 <>
69 <SchemaMarkup schema={schema} />
70
71 {/* Hero */}
72 <section className="relative flex min-h-screen items-center justify-center overflow-hidden">
73 <Container className="relative text-center">
74 <p className="animate-fade-up text-xs font-medium uppercase tracking-widest text-forest-600 opacity-0">
75 Marco Reid Courts
76 </p>
77 <h1 className="mt-8 animate-fade-up-1 text-hero font-serif opacity-0">
78 <span className="text-forest-500">AI infrastructure</span>
79 <br />
80 <span className="text-navy-700">for every courtroom on earth.</span>
81 </h1>
82 <p className="mx-auto mt-8 max-w-2xl animate-fade-up-2 text-xl leading-relaxed text-navy-400 opacity-0">
83 Courts run on paper, fax, and software written before the iPhone. Court reporters
84 are retiring faster than they're replaced. Pro se litigants drown the docket.
85 Marco Reid Courts is the complete AI platform for the third branch of government —
86 five products that turn the courtroom into the most advanced room in the building.
87 </p>
88 <div className="mt-12 animate-fade-up-3 flex justify-center gap-4 opacity-0">
89 <Button href="/courts/pilot" size="lg">Request a pilot</Button>
90 <Button href="#products" variant="secondary" size="lg">See the suite</Button>
91 </div>
92 </Container>
93 <div className="absolute bottom-0 left-0 right-0 h-40 bg-gradient-to-t from-white to-transparent" />
94 </section>
95
96 <div className="h-px bg-navy-100 mx-auto max-w-sm" />
97
98 {/* Stats */}
99 <section className="py-32 sm:py-44" aria-label="Why now">
100 <Container>
101 <Reveal>
102 <h2 className="text-center text-display font-serif text-navy-700">
103 Why now.
104 </h2>
105 </Reveal>
106 <div className="mt-16 grid gap-8 sm:grid-cols-3 text-center">
107 <Reveal delay={0.1}>
108 <p className="font-serif text-display text-navy-700">
109 <AnimatedCounter end={70} suffix="%" />
110 </p>
111 <p className="mt-2 text-sm text-navy-400">civil cases with at least one pro se party</p>
112 <p className="mt-1 text-xs text-navy-300">Courts cannot scale fast enough.</p>
113 </Reveal>
114 <Reveal delay={0.2}>
115 <p className="font-serif text-display text-forest-600">
116 <AnimatedCounter end={11000} suffix="+" />
117 </p>
118 <p className="mt-2 text-sm text-navy-400">stenographer shortage in the US alone</p>
119 <p className="mt-1 text-xs text-navy-300">A profession in collapse.</p>
120 </Reveal>
121 <Reveal delay={0.3}>
122 <p className="font-serif text-display text-forest-600">
123 <AnimatedCounter end={195} />
124 </p>
125 <p className="mt-2 text-sm text-navy-400">jurisdictions worldwide</p>
126 <p className="mt-1 text-xs text-navy-300">Every one of them needs this.</p>
127 </Reveal>
128 </div>
129 </Container>
130 </section>
131
132 <div className="h-px bg-navy-100 mx-auto max-w-sm" />
133
134 {/* The five products */}
135 <section id="products" className="py-32 sm:py-44" aria-label="The five products">
136 <Container>
137 <Reveal>
138 <p className="text-center text-xs font-medium uppercase tracking-widest text-forest-600">
139 Five products. One platform.
140 </p>
141 <h2 className="mt-6 text-center text-display font-serif text-navy-700">
142 Everything a courtroom needs.
143 </h2>
144 </Reveal>
145
146 <div className="mx-auto mt-16 max-w-4xl space-y-4">
147 {products.map((p, i) => (
148 <Reveal key={p.href} delay={0.05 * i}>
149 <Link
150 href={p.href}
151 className="block rounded-xl border border-navy-100 bg-white p-8 shadow-card transition-all duration-300 hover:shadow-card-hover hover:-translate-y-0.5"
152 >
153 <div className="flex items-baseline justify-between gap-4">
154 <p className="text-xs font-semibold uppercase tracking-widest text-forest-600">
155 {p.tag}
156 </p>
157 <p className="text-xs font-medium text-navy-300">{p.name}</p>
158 </div>
159 <h3 className="mt-4 font-serif text-headline text-navy-700">
160 {p.headline}
161 </h3>
162 <p className="mt-4 leading-relaxed text-navy-400">{p.body}</p>
163 <p className="mt-6 text-sm font-semibold text-forest-600">Learn more →</p>
164 </Link>
165 </Reveal>
166 ))}
167 </div>
168
169 <div className="mx-auto mt-16 max-w-2xl">
170 <AiDisclaimer />
171 </div>
172 </Container>
173 </section>
174
175 {/* The vision */}
176 <section className="py-32 sm:py-44" aria-label="The vision">
177 <Container narrow>
178 <Reveal>
179 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
180 The vision
181 </p>
182 <h2 className="mt-6 text-display font-serif text-navy-700">
183 The third branch of government deserves first-class technology.
184 </h2>
185 </Reveal>
186 <Reveal delay={0.1}>
187 <p className="mt-8 text-xl leading-relaxed text-navy-400">
188 Legislative chambers got laptops a decade ago. The executive runs on cloud
189 dashboards. The judiciary still runs on paper, DVDs in evidence lockers, and
190 software bought from one vendor in 2003 that nobody can afford to replace.
191 </p>
192 </Reveal>
193 <Reveal delay={0.15}>
194 <p className="mt-6 text-xl leading-relaxed text-navy-400">
195 Marco Reid Courts is built for every court in every country — from a county
196 traffic division in Ohio to a constitutional court in Nairobi. Modular. Sovereign.
197 Compliant. Designed to be impossible to replace once it’s in.
198 </p>
199 </Reveal>
200 </Container>
201 </section>
202
203 {/* CTA */}
204 <section className="relative py-32 sm:py-44" aria-label="Pilot">
205 <Container className="relative text-center">
206 <Reveal>
207 <h2 className="text-display font-serif text-forest-500">
208 Run a pilot in your courtroom.
209 </h2>
210 </Reveal>
211 <Reveal delay={0.1}>
212 <p className="mt-6 text-xl text-navy-400">
213 Court procurement is relationship-driven. Tell us about your court and we’ll
214 put a working pilot in your hands within 30 days.
215 </p>
216 </Reveal>
217 <Reveal delay={0.2}>
218 <div className="mt-12 flex justify-center gap-4">
219 <Button href="/courts/pilot" size="lg">Request a pilot</Button>
220 <Button href="/contact" variant="secondary" size="lg">Talk to us</Button>
221 </div>
222 </Reveal>
223 </Container>
224 </section>
225 </>
226 );
227}
Addedapp/(marketing)/courts/pilot/PilotForm.tsx+144−0View fileUnifiedSplit
@@ -0,0 +1,144 @@
1"use client";
2
3import { useState } from "react";
4
5const products = [
6 { id: "reporter", label: "Reporter (transcription)" },
7 { id: "docket", label: "Docket (scheduling)" },
8 { id: "filings", label: "Filings (e-filing)" },
9 { id: "bench", label: "Bench (judicial AI)" },
10 { id: "public", label: "Public (transparency)" },
11];
12
13export default function PilotForm() {
14 const [submitted, setSubmitted] = useState(false);
15 const [submitting, setSubmitting] = useState(false);
16 const [error, setError] = useState<string | null>(null);
17
18 async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
19 e.preventDefault();
20 setSubmitting(true);
21 setError(null);
22 const form = new FormData(e.currentTarget);
23 const payload = {
24 name: form.get("name"),
25 role: form.get("role"),
26 court: form.get("court"),
27 jurisdiction: form.get("jurisdiction"),
28 email: form.get("email"),
29 phone: form.get("phone"),
30 products: products.filter((p) => form.get(`product_${p.id}`)).map((p) => p.id),
31 useCase: form.get("useCase"),
32 };
33 try {
34 const res = await fetch("/api/courts/pilot", {
35 method: "POST",
36 headers: { "Content-Type": "application/json" },
37 body: JSON.stringify(payload),
38 });
39 if (!res.ok) throw new Error("Submission failed");
40 setSubmitted(true);
41 } catch (err) {
42 setError(err instanceof Error ? err.message : "Submission failed");
43 } finally {
44 setSubmitting(false);
45 }
46 }
47
48 if (submitted) {
49 return (
50 <div className="rounded-xl border border-forest-200 bg-forest-50 p-8 text-center">
51 <h2 className="font-serif text-headline text-forest-700">Request received.</h2>
52 <p className="mt-4 text-navy-500">
53 We’ll be in touch within two business days.
54 </p>
55 </div>
56 );
57 }
58
59 return (
60 <form onSubmit={handleSubmit} className="space-y-6">
61 <div className="grid gap-6 sm:grid-cols-2">
62 <Field name="name" label="Your name" required />
63 <Field name="role" label="Role" placeholder="Judge, court administrator, clerk..." required />
64 <Field name="court" label="Court" placeholder="e.g. Cook County Circuit Court" required />
65 <Field name="jurisdiction" label="Jurisdiction" placeholder="e.g. Illinois, USA" required />
66 <Field name="email" label="Work email" type="email" required />
67 <Field name="phone" label="Phone" type="tel" />
68 </div>
69
70 <div>
71 <label className="block text-sm font-semibold text-navy-700">Products of interest</label>
72 <div className="mt-3 grid gap-2 sm:grid-cols-2">
73 {products.map((p) => (
74 <label
75 key={p.id}
76 className="flex items-center gap-3 rounded-lg border border-navy-100 bg-white p-3 text-sm text-navy-600"
77 >
78 <input
79 type="checkbox"
80 name={`product_${p.id}`}
81 className="h-4 w-4 rounded border-navy-200 text-forest-600"
82 />
83 {p.label}
84 </label>
85 ))}
86 </div>
87 </div>
88
89 <div>
90 <label htmlFor="useCase" className="block text-sm font-semibold text-navy-700">
91 Use case
92 </label>
93 <textarea
94 id="useCase"
95 name="useCase"
96 rows={5}
97 required
98 placeholder="What problem are you trying to solve? What does success look like?"
99 className="mt-2 w-full rounded-lg border border-navy-200 bg-white p-3 text-navy-700 focus:border-forest-500 focus:outline-none focus:ring-1 focus:ring-forest-500"
100 />
101 </div>
102
103 {error && <p className="text-sm text-red-600">{error}</p>}
104
105 <button
106 type="submit"
107 disabled={submitting}
108 className="rounded-full bg-forest-600 px-8 py-3 font-semibold text-white shadow-card transition-all hover:bg-forest-700 disabled:opacity-60"
109 >
110 {submitting ? "Submitting..." : "Request pilot"}
111 </button>
112 </form>
113 );
114}
115
116function Field({
117 name,
118 label,
119 type = "text",
120 placeholder,
121 required,
122}: {
123 name: string;
124 label: string;
125 type?: string;
126 placeholder?: string;
127 required?: boolean;
128}) {
129 return (
130 <div>
131 <label htmlFor={name} className="block text-sm font-semibold text-navy-700">
132 {label}
133 </label>
134 <input
135 id={name}
136 name={name}
137 type={type}
138 placeholder={placeholder}
139 required={required}
140 className="mt-2 w-full rounded-lg border border-navy-200 bg-white p-3 text-navy-700 focus:border-forest-500 focus:outline-none focus:ring-1 focus:ring-forest-500"
141 />
142 </div>
143 );
144}
Addedapp/(marketing)/courts/pilot/page.tsx+32−0View fileUnifiedSplit
@@ -0,0 +1,32 @@
1import type { Metadata } from "next";
2import Container from "@/app/components/shared/Container";
3import PilotForm from "./PilotForm";
4
5export const metadata: Metadata = {
6 title: "Request a Pilot \u2014 Marco Reid Courts",
7 description:
8 "Run Marco Reid Courts in your courtroom. Tell us about your court and we'll put a working pilot in your hands within 30 days.",
9};
10
11export default function PilotPage() {
12 return (
13 <section className="py-32 sm:py-44">
14 <Container narrow>
15 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
16 Marco Reid Courts
17 </p>
18 <h1 className="mt-6 text-display font-serif text-navy-700">
19 Request a pilot.
20 </h1>
21 <p className="mt-6 text-xl leading-relaxed text-navy-400">
22 Court procurement is relationship-driven. Tell us about your court, the products you
23 want to evaluate, and the use case you’re trying to solve. We’ll respond
24 within two business days and put a working pilot in your hands within 30 days.
25 </p>
26 <div className="mt-12">
27 <PilotForm />
28 </div>
29 </Container>
30 </section>
31 );
32}
Addedapp/(marketing)/courts/public/page.tsx+68−0View fileUnifiedSplit
@@ -0,0 +1,68 @@
1import type { Metadata } from "next";
2import Container from "@/app/components/shared/Container";
3import Button from "@/app/components/shared/Button";
4import Reveal from "@/app/components/effects/Reveal";
5
6export const metadata: Metadata = {
7 title: "Marco Reid Public \u2014 Open Justice, Finally Open",
8 description:
9 "Livestreaming, searchable transcripts, opinion publication, public docket access. The transparency every constitution promises and few courts deliver.",
10};
11
12export default function PublicPage() {
13 return (
14 <>
15 <section className="relative flex min-h-[80vh] items-center justify-center">
16 <Container className="text-center">
17 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
18 Marco Reid Public
19 </p>
20 <h1 className="mt-8 text-hero font-serif">
21 <span className="text-forest-500">Open justice,</span>
22 <br />
23 <span className="text-navy-700">finally open.</span>
24 </h1>
25 <p className="mx-auto mt-8 max-w-2xl text-xl leading-relaxed text-navy-400">
26 Open courts are a constitutional principle. In practice, transcripts cost dollars per
27 page, opinions take months to publish, and live access ends at the courtroom door.
28 Marco Reid Public turns every proceeding into searchable, streamable, downloadable
29 public record — with sealing controls baked in.
30 </p>
31 <div className="mt-12 flex justify-center gap-4">
32 <Button href="/courts/pilot" size="lg">Request a pilot</Button>
33 <Button href="/courts" variant="secondary" size="lg">Back to Courts</Button>
34 </div>
35 </Container>
36 </section>
37
38 <section className="py-32" aria-label="Features">
39 <Container>
40 <Reveal>
41 <h2 className="text-center text-display font-serif text-navy-700">
42 What it does.
43 </h2>
44 </Reveal>
45 <div className="mx-auto mt-16 grid max-w-3xl gap-3 sm:grid-cols-2">
46 {[
47 { title: "Hearing livestream", desc: "Public stream with closed captioning" },
48 { title: "Transcript search", desc: "Every word, every hearing, instantly searchable" },
49 { title: "Opinion publication", desc: "Auto-publish with citation linking" },
50 { title: "Public docket access", desc: "Plain-language case summaries" },
51 { title: "Sealing controls", desc: "Granular redaction for sealed matters" },
52 { title: "Press portal", desc: "Press credentials, embargoed releases" },
53 { title: "Open data API", desc: "Bulk access for researchers and journalists" },
54 { title: "Multi-language", desc: "Auto-translated transcripts for the public" },
55 ].map((f) => (
56 <Reveal key={f.title} delay={0.05}>
57 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
58 <p className="font-semibold text-navy-700">{f.title}</p>
59 <p className="mt-2 text-sm text-navy-400">{f.desc}</p>
60 </div>
61 </Reveal>
62 ))}
63 </div>
64 </Container>
65 </section>
66 </>
67 );
68}
Addedapp/(marketing)/courts/reporter/page.tsx+67−0View fileUnifiedSplit
@@ -0,0 +1,67 @@
1import type { Metadata } from "next";
2import Container from "@/app/components/shared/Container";
3import Button from "@/app/components/shared/Button";
4import Reveal from "@/app/components/effects/Reveal";
5
6export const metadata: Metadata = {
7 title: "Marco Reid Reporter \u2014 Real-Time AI Court Transcription",
8 description:
9 "End the court reporter shortage. Real-time AI transcription with speaker diarisation, legal terminology, certified output, and 100+ languages.",
10};
11
12export default function ReporterPage() {
13 return (
14 <>
15 <section className="relative flex min-h-[80vh] items-center justify-center">
16 <Container className="text-center">
17 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
18 Marco Reid Reporter
19 </p>
20 <h1 className="mt-8 text-hero font-serif">
21 <span className="text-forest-500">Real-time transcription.</span>
22 <br />
23 <span className="text-navy-700">Every word, every hearing.</span>
24 </h1>
25 <p className="mx-auto mt-8 max-w-2xl text-xl leading-relaxed text-navy-400">
26 The US is short more than 11,000 stenographers and the gap is widening every year.
27 Marco Reid Reporter is the answer: real-time AI transcription trained on legal
28 vocabulary, with speaker diarisation, certified output, and support for 100+ languages.
29 </p>
30 <div className="mt-12 flex justify-center gap-4">
31 <Button href="/courts/pilot" size="lg">Request a pilot</Button>
32 <Button href="/courts" variant="secondary" size="lg">Back to Courts</Button>
33 </div>
34 </Container>
35 </section>
36
37 <section className="py-32" aria-label="Features">
38 <Container>
39 <Reveal>
40 <h2 className="text-center text-display font-serif text-navy-700">
41 What it does.
42 </h2>
43 </Reveal>
44 <div className="mx-auto mt-16 grid max-w-3xl gap-3 sm:grid-cols-2">
45 {[
46 { title: "Real-time transcript", desc: "Sub-second latency on the bench monitor" },
47 { title: "Speaker diarisation", desc: "Judge, counsel, witness, interpreter — labelled" },
48 { title: "Legal terminology", desc: "Trained on case law, statutes, Latin terms, court formalities" },
49 { title: "Certified output", desc: "Court-admissible PDF with chain of custody" },
50 { title: "100+ languages", desc: "Live translation for interpreter coverage" },
51 { title: "Search & timestamps", desc: "Jump to any utterance by keyword or time" },
52 { title: "Redaction tools", desc: "Auto-flag PII for sealed proceedings" },
53 { title: "Offline mode", desc: "Records and transcribes without internet" },
54 ].map((f) => (
55 <Reveal key={f.title} delay={0.05}>
56 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
57 <p className="font-semibold text-navy-700">{f.title}</p>
58 <p className="mt-2 text-sm text-navy-400">{f.desc}</p>
59 </div>
60 </Reveal>
61 ))}
62 </div>
63 </Container>
64 </section>
65 </>
66 );
67}
Modifiedapp/(marketing)/dictation/page.tsx+3−2View fileUnifiedSplit
@@ -260,8 +260,9 @@ export default function DictationPage() {
260260 </p>
261261 </Reveal>
262262 <Reveal delay={0.2}>
263 <div className="mt-12 flex justify-center gap-4">
264 <Button href="/pricing" size="lg">See pricing</Button>
263 <div className="mt-12 flex flex-wrap justify-center gap-4">
264 <Button href="/voice" size="lg">Try it now</Button>
265 <Button href="/pricing" variant="secondary" size="lg">See pricing</Button>
265266 <Button href="/law" variant="secondary" size="lg">Explore Law</Button>
266267 </div>
267268 </Reveal>
Addedapp/(marketing)/immigration/page.tsx+256−0View fileUnifiedSplit
@@ -0,0 +1,256 @@
1import type { Metadata } from "next";
2import { BRAND } from "@/lib/constants";
3import Container from "@/app/components/shared/Container";
4import Button from "@/app/components/shared/Button";
5import SchemaMarkup from "@/app/components/shared/SchemaMarkup";
6import AiDisclaimer from "@/app/components/shared/AiDisclaimer";
7import AnimatedCounter from "@/app/components/effects/AnimatedCounter";
8import Reveal from "@/app/components/effects/Reveal";
9
10export const metadata: Metadata = {
11 title: "Marco Reid Immigration \u2014 AI-Powered Immigration Compliance",
12 description:
13 "Visa case management, RFE drafting, deadline tracking, USCIS form automation, and Marco for immigration. Built for immigration attorneys and compliance teams.",
14};
15
16const schema = {
17 "@context": "https://schema.org",
18 "@type": "SoftwareApplication",
19 name: "Marco Reid Immigration",
20 applicationCategory: "LegalService",
21 operatingSystem: "Web",
22 description:
23 "AI-powered immigration compliance platform with case management, USCIS form automation, RFE drafting, and verified regulatory research for immigration practitioners.",
24 url: `${BRAND.url}/immigration`,
25};
26
27export default function ImmigrationPage() {
28 return (
29 <>
30 <SchemaMarkup schema={schema} />
31
32 {/* Hero */}
33 <section className="relative flex min-h-screen items-center justify-center overflow-hidden">
34 <Container className="relative text-center">
35 <p className="animate-fade-up text-xs font-medium uppercase tracking-widest text-forest-600 opacity-0">
36 Marco Reid Immigration
37 </p>
38 <h1 className="mt-8 animate-fade-up-1 text-hero font-serif opacity-0">
39 <span className="text-forest-500">AI-powered immigration</span>
40 <br />
41 <span className="text-navy-700">compliance, end to end.</span>
42 </h1>
43 <p className="mx-auto mt-8 max-w-2xl animate-fade-up-2 text-xl leading-relaxed text-navy-400 opacity-0">
44 Case management, USCIS form automation, deadline tracking, RFE drafting, and Marco
45 for immigration — verified research across the INA, 8 CFR, USCIS Policy Manual,
46 and consular guidance. Built for the practitioners who can’t afford to miss a deadline.
47 </p>
48 <div className="mt-12 animate-fade-up-3 opacity-0">
49 <Button href="/pricing" size="lg">See pricing</Button>
50 </div>
51 </Container>
52 <div className="absolute bottom-0 left-0 right-0 h-40 bg-gradient-to-t from-white to-transparent" />
53 </section>
54
55 <div className="h-px bg-navy-100 mx-auto max-w-sm" />
56
57 {/* Stats */}
58 <section className="py-32 sm:py-44" aria-label="Impact">
59 <Container>
60 <Reveal>
61 <h2 className="text-center text-display font-serif text-navy-700">
62 The numbers speak for themselves.
63 </h2>
64 </Reveal>
65 <div className="mt-16 grid gap-8 sm:grid-cols-3 text-center">
66 <Reveal delay={0.1}>
67 <p className="font-serif text-display text-navy-700">
68 <AnimatedCounter end={70} suffix="%" />
69 </p>
70 <p className="mt-2 text-sm text-navy-400">faster RFE responses</p>
71 <p className="mt-1 text-xs text-navy-300">Drafted, cited, ready to review.</p>
72 </Reveal>
73 <Reveal delay={0.2}>
74 <p className="font-serif text-display text-forest-600">
75 <AnimatedCounter end={0} />
76 </p>
77 <p className="mt-2 text-sm text-navy-400">missed deadlines</p>
78 <p className="mt-1 text-xs text-navy-300">Automated tracking across every case.</p>
79 </Reveal>
80 <Reveal delay={0.3}>
81 <p className="font-serif text-display text-forest-600">
82 <AnimatedCounter end={100} suffix="+" />
83 </p>
84 <p className="mt-2 text-sm text-navy-400">USCIS forms supported</p>
85 <p className="mt-1 text-xs text-navy-300">I-130, I-485, I-765, I-129, N-400, and more.</p>
86 </Reveal>
87 </div>
88 </Container>
89 </section>
90
91 <div className="h-px bg-navy-100 mx-auto max-w-sm" />
92
93 {/* The problem */}
94 <section className="py-32 sm:py-44" aria-label="The problem">
95 <Container narrow>
96 <Reveal>
97 <p className="text-xs font-medium uppercase tracking-widest text-forest-600">
98 The problem
99 </p>
100 <h2 className="mt-6 text-display font-serif text-navy-700">
101 Immigration practice runs on deadlines no one can afford to miss.
102 </h2>
103 </Reveal>
104 <Reveal delay={0.1}>
105 <p className="mt-8 text-xl leading-relaxed text-navy-400">
106 An RFE deadline missed by a day can cost a client their status. A form filed on the
107 wrong edition gets rejected and restarts the clock. Policy changes weekly. Case
108 management software was built in 2008 and looks it.
109 </p>
110 </Reveal>
111 <Reveal delay={0.15}>
112 <p className="mt-6 text-xl leading-relaxed text-navy-400">
113 Marco Reid Immigration brings every workflow into one platform. Cases, forms,
114 deadlines, RFEs, client intake, and verified research — connected, current,
115 and intelligent.
116 </p>
117 </Reveal>
118 </Container>
119 </section>
120
121 <div className="h-px bg-navy-100 mx-auto max-w-sm" />
122
123 {/* Feature stories */}
124 <section className="py-32 sm:py-44" aria-label="Features">
125 <Container>
126 <Reveal>
127 <p className="text-center text-xs font-medium uppercase tracking-widest text-forest-600">
128 Every workflow. One platform.
129 </p>
130 <h2 className="mt-6 text-center text-display font-serif text-navy-700">
131 What Marco Reid Immigration does.
132 </h2>
133 </Reveal>
134
135 <div className="mx-auto mt-16 max-w-3xl space-y-4">
136 <Reveal delay={0.05}>
137 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
138 <p className="text-xs font-semibold uppercase tracking-widest text-forest-600">Case management</p>
139 <h3 className="mt-4 font-serif text-headline text-navy-700">
140 Every case. Every deadline. Every document.
141 </h3>
142 <p className="mt-4 leading-relaxed text-navy-400">
143 Track I-130, I-485, I-129, I-765, N-400 and every other case type from intake
144 to approval. Automatic deadline calculation from receipt date. Document
145 checklists that update with policy changes. Client portals built in.
146 </p>
147 </div>
148 </Reveal>
149
150 <Reveal delay={0.05}>
151 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
152 <p className="text-xs font-semibold uppercase tracking-widest text-forest-600">RFE drafting</p>
153 <h3 className="mt-4 font-serif text-headline text-navy-700">
154 Draft RFE responses in minutes.
155 </h3>
156 <p className="mt-4 leading-relaxed text-navy-400">
157 Upload the RFE, the case file, and the supporting evidence. Marco produces a
158 cited draft response keyed to the exact INA sections, 8 CFR provisions, and
159 USCIS Policy Manual chapters the officer requested. You review. You file.
160 </p>
161 </div>
162 </Reveal>
163
164 <Reveal delay={0.05}>
165 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
166 <p className="text-xs font-semibold tracking-widest text-purple-400">Marco for immigration</p>
167 <h3 className="mt-4 font-serif text-headline text-navy-700">
168 Verified immigration research, instantly.
169 </h3>
170 <p className="mt-4 leading-relaxed text-navy-400">
171 Every INA section, 8 CFR provision, USCIS Policy Manual chapter, AAO decision,
172 and consular guidance — verified against official sources. “Public
173 charge ground of inadmissibility for an adjustment applicant” —
174 answered instantly, cited correctly, never hallucinated.
175 </p>
176 </div>
177 </Reveal>
178
179 <Reveal delay={0.05}>
180 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card">
181 <p className="text-xs font-semibold uppercase tracking-widest text-amber-400">Form automation</p>
182 <h3 className="mt-4 font-serif text-headline text-navy-700">
183 USCIS forms, auto-filled from the case file.
184 </h3>
185 <p className="mt-4 leading-relaxed text-navy-400">
186 Intake answers populate every form in the case. Edition checks happen in real
187 time. PDF outputs match USCIS specifications exactly. No more retyping the same
188 beneficiary details across six different forms.
189 </p>
190 </div>
191 </Reveal>
192 </div>
193 </Container>
194 </section>
195
196 {/* Everything included */}
197 <section className="py-32 sm:py-44" aria-label="All features">
198 <Container>
199 <Reveal>
200 <h2 className="text-center text-display font-serif text-navy-700">
201 Everything included.
202 </h2>
203 </Reveal>
204 <div className="mx-auto mt-16 grid max-w-3xl gap-3 sm:grid-cols-2">
205 {[
206 { title: "Case management", desc: "Family, employment, humanitarian, naturalization" },
207 { title: "USCIS form automation", desc: "Auto-fill, edition checks, PDF output" },
208 { title: "Deadline tracking", desc: "RFE, NOID, biometrics, master calendar" },
209 { title: "RFE drafting", desc: "Cited responses keyed to officer requests" },
210 { title: "Client intake portals", desc: "Branded, multilingual, mobile-first" },
211 { title: "Document checklists", desc: "Updated automatically with policy changes" },
212 { title: "Marco", desc: "INA, 8 CFR, Policy Manual, AAO research with citations" },
213 { title: "Marco Reid Voice", desc: "Dictate intake notes and case updates" },
214 { title: "E-signatures", desc: "G-28s and retainers signed inside the platform" },
215 { title: "Billing & trust", desc: "Time tracking, IOLTA, marketplace payments" },
216 ].map((f) => (
217 <Reveal key={f.title} delay={0.05}>
218 <div className="rounded-xl border border-navy-100 bg-white p-6 shadow-card transition-all duration-300 hover:shadow-card-hover hover:-translate-y-0.5">
219 <p className="font-semibold text-navy-700">{f.title}</p>
220 <p className="mt-2 text-sm text-navy-400">{f.desc}</p>
221 </div>
222 </Reveal>
223 ))}
224 </div>
225 <div className="mx-auto mt-16 max-w-2xl">
226 <AiDisclaimer />
227 </div>
228 </Container>
229 </section>
230
231 {/* CTA */}
232 <section className="relative py-32 sm:py-44" aria-label="Get started">
233 <Container className="relative text-center">
234 <Reveal>
235 <h2 className="text-display font-serif text-forest-500">
236 Immigration compliance
237 <br />
238 you can stake your practice on.
239 </h2>
240 </Reveal>
241 <Reveal delay={0.1}>
242 <p className="mt-6 text-xl text-navy-400">
243 From intake to approval. One platform. Zero missed deadlines.
244 </p>
245 </Reveal>
246 <Reveal delay={0.2}>
247 <div className="mt-12 flex justify-center gap-4">
248 <Button href="/pricing" size="lg">See pricing</Button>
249 <Button href="/law" variant="secondary" size="lg">Explore Law</Button>
250 </div>
251 </Reveal>
252 </Container>
253 </section>
254 </>
255 );
256}
Modifiedapp/(marketing)/law/page.tsx+7−7View fileUnifiedSplit
@@ -15,7 +15,7 @@ const MockupReveal = dynamic(() => import("@/app/components/effects/MockupReveal
1515export const metadata: Metadata = {
1616 title: "Marco Reid Legal \u2014 The Operating System for Your Legal Practice",
1717 description:
18 "Full-stack legal practice management powered by AI. Case management, billing, trust accounting, document drafting, court-rules calendaring, and The Oracle legal research. One platform replaces everything.",
18 "Full-stack legal practice management powered by AI. Case management, billing, trust accounting, document drafting, court-rules calendaring, and Marco legal research. One platform replaces everything.",
1919};
2020
2121const schema = {
@@ -165,7 +165,7 @@ export default function LawPage() {
165165 <p className="mt-4 leading-relaxed text-navy-400">
166166 You’re drafting a non-compete clause. Not sure about California’s standard.
167167 You hit  <span className="rounded bg-navy-100 px-2 py-0.5 font-mono text-sm text-forest-600">⌘K</span> 
168 — The Oracle slides in from the right. You type your question. Three verified
168 — Marco slides in from the right. You type your question. Three verified
169169 cases in under 3 seconds. You click “Insert citation.” It drops into your document
170170 at the cursor, formatted correctly, verified. Total time: 25 seconds.
171171 On Westlaw, that’s 5 minutes and your flow is destroyed.
@@ -260,7 +260,7 @@ export default function LawPage() {
260260 </h3>
261261 <p className="mt-4 leading-relaxed text-navy-400">
262262 The client specifies where they’ll operate and what the business does.
263 The Oracle — Legal and Accounting simultaneously — recommends the optimal
263 Marco — Legal and Accounting simultaneously — recommends the optimal
264264 entity type per jurisdiction, analyses tax implications across all countries involved,
265265 recommends an asset protection structure, identifies legal vulnerabilities,
266266 generates all formation documents pre-populated with client data, routes them for
@@ -292,7 +292,7 @@ export default function LawPage() {
292292 Schedule depositions inside the platform. Record video built-in or via Zoom.
293293 Marco Reid Voice transcribes the entire deposition in real time — legal vocabulary,
294294 speaker identification, timestamps. Pull up exhibits from the matter files instantly.
295 Query The Oracle mid-deposition to check a citation the witness mentions.
295 Query Marco mid-deposition to check a citation the witness mentions.
296296 AI generates a structured summary with key testimony, objections, and action items.
297297 Every word searchable, linked to the matter, timestamped to the video.
298298 Opposing counsel gets access through the platform — another hook.
@@ -316,7 +316,7 @@ export default function LawPage() {
316316 Electronic filing with courts where APIs exist. Real-time courtroom transcription
317317 via Marco Reid Voice. Digital exhibit management on iPad or laptop. Court-rules
318318 calendaring that auto-calculates every downstream deadline. Judge analytics showing
319 ruling patterns, motion grant rates, and sentencing trends. And The Oracle
319 ruling patterns, motion grant rates, and sentencing trends. And Marco
320320 available on your iPad mid-hearing — verify a citation opposing counsel just
321321 raised in 3 seconds. That is a superpower in a courtroom.
322322 </p>
@@ -353,7 +353,7 @@ export default function LawPage() {
353353 { title: "Instant messaging", desc: "Matter-centric, encrypted, archived, exportable" },
354354 { title: "Email integration", desc: "Gmail and Outlook inside Marco Reid with Oracle access" },
355355 { title: "Scheduling & meetings", desc: "Calendar sync, Zoom links, post-meeting AI summaries" },
356 { title: "The Oracle", desc: "AI legal research with citation verification, inline everywhere" },
356 { title: "Marco", desc: "AI legal research with citation verification, inline everywhere" },
357357 { title: "Company incorporation", desc: "Automated entity formation — LLC, Ltd, C-Corp — with AI-populated documents and e-filing" },
358358 { title: "Conflict checking", desc: "Automated conflict of interest detection across all matters and parties" },
359359 { title: "Depositions", desc: "Video, real-time AI transcription, exhibit management, and AI summaries" },
@@ -399,7 +399,7 @@ export default function LawPage() {
399399 <Reveal delay={0.2}>
400400 <div className="mt-12 flex justify-center gap-4">
401401 <Button href="/pricing" size="lg">See pricing</Button>
402 <Button href="/oracle" variant="secondary" size="lg">Explore The Oracle</Button>
402 <Button href="/oracle" variant="secondary" size="lg">Explore Marco</Button>
403403 </div>
404404 </Reveal>
405405 </Container>
Modifiedapp/(marketing)/oracle/page.tsx+12−12View fileUnifiedSplit
@@ -12,7 +12,7 @@ const MockupReveal = dynamic(() => import("@/app/components/effects/MockupReveal
1212
1313
1414export const metadata: Metadata = {
15 title: "The Oracle \u2014 The Most Intelligent Legal and Accounting Research Engine Ever Built",
15 title: "Marco \u2014 The Most Intelligent Legal and Accounting Research Engine Ever Built",
1616 description:
1717 "Cross-domain legal and accounting AI research. Every citation verified. Ask questions that span both disciplines. Tax codes, IRS rulings, case law, and regulations \u2014 the research engine nobody else can build.",
1818};
@@ -20,7 +20,7 @@ export const metadata: Metadata = {
2020const schema = {
2121 "@context": "https://schema.org",
2222 "@type": "SoftwareApplication",
23 name: "The Oracle",
23 name: "Marco",
2424 applicationCategory: "Research",
2525 operatingSystem: "Web",
2626 description: "Cross-domain legal and accounting AI research with mandatory citation verification. Public domain case law, statutes, tax codes, and regulations.",
@@ -36,7 +36,7 @@ export default function OraclePage() {
3636 <section className="relative flex min-h-screen items-center justify-center overflow-hidden">
3737 <Container className="relative text-center">
3838 <p className="animate-fade-up text-xs font-medium uppercase tracking-widest text-plum-600 opacity-0">
39 The Oracle
39 Marco
4040 </p>
4141 <h1 className="mt-8 animate-fade-up-1 text-hero font-serif opacity-0">
4242 <span className="text-navy-700">The most intelligent</span>
@@ -68,7 +68,7 @@ export default function OraclePage() {
6868 <div className="h-px bg-navy-100 mx-auto max-w-sm" />
6969
7070 {/* The killer workflow — Oracle draw-down */}
71 <section className="py-32 sm:py-44" aria-label="The Oracle draw-down">
71 <section className="py-32 sm:py-44" aria-label="Marco draw-down">
7272 <Container narrow>
7373 <Reveal>
7474 <p className="text-xs font-medium uppercase tracking-widest text-plum-600">
@@ -91,10 +91,10 @@ export default function OraclePage() {
9191 </Reveal>
9292 <Reveal delay={0.15}>
9393 <div className="mt-4 rounded-2xl border border-purple-500/20 bg-plum-50 p-8">
94 <p className="text-xs font-semibold tracking-widest text-plum-600">With The Oracle</p>
94 <p className="text-xs font-semibold tracking-widest text-plum-600">With Marco</p>
9595 <p className="mt-4 leading-relaxed text-navy-400">
9696 You’re drafting. You hit  <span className="rounded bg-navy-100 px-2 py-0.5 font-mono text-sm text-plum-600">⌘K</span> .
97 The Oracle slides in from the right — you never leave the document.
97 Marco slides in from the right — you never leave the document.
9898 You type “California non-compete enforceability standard.”
9999 Three verified cases in under 3 seconds. You click “Insert citation.”
100100 It drops directly into your document at the cursor position, formatted correctly,
@@ -152,7 +152,7 @@ export default function OraclePage() {
152152 </div>
153153 <div>
154154 <p className="text-xs font-bold tracking-wider text-forest-600">
155 The Oracle — Accounting
155 Marco — Accounting
156156 </p>
157157 <h2 className="font-serif text-display text-navy-800">
158158 Tax research answered in seconds, not hours.
@@ -164,7 +164,7 @@ export default function OraclePage() {
164164 <Reveal delay={0.1}>
165165 <p className="mt-6 max-w-2xl text-lg leading-relaxed text-navy-400">
166166 Most accountants ring a taxation agent to verify what they’re doing is correct.
167 That call takes time. The answer takes longer. With The Oracle for Accounting,
167 That call takes time. The answer takes longer. With Marco for Accounting,
168168 every IRS code section, every revenue ruling, every regulatory citation is verified
169169 against official sources and returned in seconds. The CPA never leaves their workflow.
170170 </p>
@@ -196,7 +196,7 @@ export default function OraclePage() {
196196 for a married couple filing jointly in 2026?”
197197 </p>
198198 <p className="mt-4 text-sm leading-relaxed text-navy-400">
199 The Oracle returns the exact threshold, the relevant IRC section, the applicable
199 Marco returns the exact threshold, the relevant IRC section, the applicable
200200 Treasury Regulation, and links to the official IRS source — all verified,
201201 all cited, all in under 3 seconds. The CPA who used to spend 20 minutes searching
202202 IRS.gov now has their answer before they finish their coffee.
@@ -218,7 +218,7 @@ export default function OraclePage() {
218218 </div>
219219 <div>
220220 <p className="text-xs font-bold tracking-wider text-navy-500">
221 The Oracle — Intellectual Property
221 Marco — Intellectual Property
222222 </p>
223223 <h2 className="font-serif text-display text-navy-800">
224224 Patents. Trademarks. Copyright. Trade secrets.
@@ -230,10 +230,10 @@ export default function OraclePage() {
230230 <Reveal delay={0.1}>
231231 <p className="mt-6 max-w-2xl text-lg leading-relaxed text-navy-400">
232232 IP attorneys bill $500–$800/hour and do enormous amounts of research.
233 The Oracle for IP is a dedicated domain that understands patent claims,
233 Marco for IP is a dedicated domain that understands patent claims,
234234 trademark likelihood of confusion, prior art analysis, and IP case law.
235235 And because IP work always has tax implications — licensing revenue,
236 IP holding entity structures, R&D credits — The Oracle spans
236 IP holding entity structures, R&D credits — Marco spans
237237 both IP law and accounting in a single query.
238238 </p>
239239 </Reveal>
Modifiedapp/(marketing)/page.tsx+11−11View fileUnifiedSplit
@@ -176,7 +176,7 @@ export default function HomePage() {
176176 <p className="text-2xl">⌘</p>
177177 <h3 className="mt-3 font-serif text-lg text-navy-700">Research mid-document</h3>
178178 <p className="mt-2 text-sm leading-relaxed text-navy-400">
179 Hit ⌘K. The Oracle slides in. “California non-compete standard.”
179 Hit ⌘K. Marco slides in. “California non-compete standard.”
180180 Three verified cases in 3 seconds. Insert citation at cursor. 25 seconds total.
181181 Westlaw takes 5 minutes.
182182 </p>
@@ -237,9 +237,9 @@ export default function HomePage() {
237237 <div className="mx-auto max-w-6xl px-6 sm:px-8 lg:px-12"><div className="h-px bg-navy-100" /></div>
238238
239239 {/* ============================================================ */}
240 {/* PRODUCT 2: The Oracle — MASSIVE showcase */}
240 {/* PRODUCT 2: Marco — MASSIVE showcase */}
241241 {/* ============================================================ */}
242 <section className="py-24 sm:py-36 lg:py-44" aria-label="The Oracle">
242 <section className="py-24 sm:py-36 lg:py-44" aria-label="Marco">
243243 <div className="mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
244244 <Reveal>
245245 <div className="flex items-center gap-4">
@@ -248,10 +248,10 @@ export default function HomePage() {
248248 </div>
249249 <div>
250250 <p className="text-xs font-bold tracking-wider text-plum-600">
251 The Oracle
251 Marco
252252 </p>
253253 <h2 className="font-serif text-display text-navy-800">
254 You spend 4 hours researching what The Oracle answers in 25 seconds.
254 You spend 4 hours researching what Marco answers in 25 seconds.
255255 </h2>
256256 </div>
257257 </div>
@@ -267,7 +267,7 @@ export default function HomePage() {
267267 </Reveal>
268268 <Reveal delay={0.15}>
269269 <p className="mt-4 max-w-2xl text-lg font-medium text-navy-600">
270 Hit ⌘K. The Oracle slides in without leaving your document. Type your question.
270 Hit ⌘K. Marco slides in without leaving your document. Type your question.
271271 Three verified cases in 3 seconds. Click “Insert citation.” Done. 25 seconds.
272272 And it works for both legal AND accounting research simultaneously —
273273 because nobody else owns both sides. Cross-domain queries that Westlaw, LexisNexis,
@@ -330,7 +330,7 @@ export default function HomePage() {
330330
331331 <Reveal delay={0.2}>
332332 <div className="mt-12 flex gap-4">
333 <Button href="/oracle">Learn more about The Oracle</Button>
333 <Button href="/oracle">Learn more about Marco</Button>
334334 <Button href="/pricing" variant="ghost">Pricing →</Button>
335335 </div>
336336 </Reveal>
@@ -366,7 +366,7 @@ export default function HomePage() {
366366 Every dictation tool on the market is an island. Dragon can’t log a billing entry.
367367 WisprFlow can’t schedule a meeting tagged to a matter. Otter can’t query a legal
368368 research database. They transcribe words. That’s it. They sit outside your workflow.
369 transcribe. It files motions, logs billing, schedules meetings, queries The Oracle,
369 transcribe. It files motions, logs billing, schedules meetings, queries Marco,
370370 and sends matter-tagged messages. All by speaking. In 9 languages.
371371 </p>
372372 </Reveal>
@@ -412,7 +412,7 @@ export default function HomePage() {
412412 },
413413 {
414414 context: "Inside documents",
415 command: "\"Ask the Oracle \u2014 California adverse possession standard, insert the controlling case.\"",
415 command: "\"Ask Marco \u2014 California adverse possession standard, insert the controlling case.\"",
416416 result: "Oracle queried. Citation inserted at cursor. Never stopped dictating.",
417417 },
418418 {
@@ -476,7 +476,7 @@ export default function HomePage() {
476476 <p className="mt-4 max-w-2xl text-lg font-medium text-navy-600">
477477 Marco Reid Courtroom: AI transcription that replaces stenographers. Tamper-evident
478478 evidence with cryptographic chain of custody. Judge analytics that tell you
479 ruling patterns before you walk in. And The Oracle on your iPad —
479 ruling patterns before you walk in. And Marco on your iPad —
480480 verify any citation in 3 seconds, mid-hearing. Permission-based. Court-admissible.
481481 </p>
482482 </Reveal>
@@ -544,7 +544,7 @@ export default function HomePage() {
544544 <p className="mt-4 max-w-2xl text-lg font-medium text-navy-600">
545545 Marco Reid Accounting: bank feeds via Plaid that never miss a transaction. AI
546546 reconciliation that turns months into minutes. Tax compliance across 50 states.
547 The Oracle for accounting — IRS code, revenue rulings, Treasury regs answered
547 Marco for accounting — IRS code, revenue rulings, Treasury regs answered
548548 in 3 seconds. Voice journal entries. And direct collaboration with lawyers on
549549 shared matters. One platform for both professions.
550550 </p>
Modifiedapp/(marketing)/pricing/page.tsx+30−4View fileUnifiedSplit
@@ -8,6 +8,28 @@ import {
88import { PricingTier } from "@/lib/types";
99import SchemaMarkup from "@/app/components/shared/SchemaMarkup";
1010import Reveal from "@/app/components/effects/Reveal";
11import SubscribeButton from "@/app/components/pricing/SubscribeButton";
12
13type PricingCategory = "legal" | "accounting" | "oracle";
14
15function priceIdFor(category: PricingCategory, tierName: string): string | undefined {
16 const key = tierName.toUpperCase().replace(/[^A-Z]/g, "_");
17 if (category === "legal") {
18 if (key.includes("STARTER")) return process.env.STRIPE_PRICE_LEGAL_STARTER;
19 if (key.includes("PROFESSIONAL")) return process.env.STRIPE_PRICE_LEGAL_PROFESSIONAL;
20 if (key.includes("FIRM")) return process.env.STRIPE_PRICE_LEGAL_FIRM;
21 }
22 if (category === "accounting") {
23 if (key.includes("STARTER")) return process.env.STRIPE_PRICE_ACCOUNTING_STARTER;
24 if (key.includes("PROFESSIONAL")) return process.env.STRIPE_PRICE_ACCOUNTING_PROFESSIONAL;
25 if (key.includes("FIRM")) return process.env.STRIPE_PRICE_ACCOUNTING_FIRM;
26 }
27 if (category === "oracle") {
28 if (key.includes("CROSS")) return process.env.STRIPE_PRICE_MARCO_CROSSDOMAIN;
29 if (key.includes("ENTERPRISE")) return process.env.STRIPE_PRICE_MARCO_ENTERPRISE;
30 }
31 return undefined;
32}
1133
1234export const metadata: Metadata = {
1335 title: "Pricing",
@@ -37,7 +59,7 @@ const schema = {
3759 ],
3860};
3961
40function PricingCard({ tier }: { tier: PricingTier }) {
62function PricingCard({ tier, category }: { tier: PricingTier; category: PricingCategory }) {
4163 return (
4264 <div
4365 className={`flex flex-col rounded-2xl p-8 transition-all duration-300 hover:-translate-y-1 ${
@@ -80,6 +102,10 @@ function PricingCard({ tier }: { tier: PricingTier }) {
80102 </li>
81103 ))}
82104 </ul>
105 <SubscribeButton
106 priceId={priceIdFor(category, tier.name)}
107 highlighted={tier.highlighted}
108 />
83109 </div>
84110 );
85111}
@@ -115,7 +141,7 @@ export default function PricingPage() {
115141 </Reveal>
116142 <div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
117143 {LAW_PRICING.map((tier) => (
118 <PricingCard key={tier.name} tier={tier} />
144 <PricingCard key={tier.name} tier={tier} category="legal" />
119145 ))}
120146 </div>
121147 </div>
@@ -138,7 +164,7 @@ export default function PricingPage() {
138164 </Reveal>
139165 <div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
140166 {ACCOUNTING_PRICING.map((tier) => (
141 <PricingCard key={tier.name} tier={tier} />
167 <PricingCard key={tier.name} tier={tier} category="accounting" />
142168 ))}
143169 </div>
144170 </div>
@@ -161,7 +187,7 @@ export default function PricingPage() {
161187 </Reveal>
162188 <div className="mx-auto mt-12 grid max-w-3xl gap-6 sm:grid-cols-2">
163189 {ORACLE_PRICING.map((tier) => (
164 <PricingCard key={tier.name} tier={tier} />
190 <PricingCard key={tier.name} tier={tier} category="oracle" />
165191 ))}
166192 </div>
167193 </div>
Addedapp/(platform)/billing/BillingActions.tsx+52−0View fileUnifiedSplit
@@ -0,0 +1,52 @@
1"use client";
2
3import { useState } from "react";
4
5export default function BillingActions({
6 hasCustomer,
7 connectOnboarded,
8}: {
9 hasCustomer: boolean;
10 connectOnboarded: boolean;
11}) {
12 const [loading, setLoading] = useState<string | null>(null);
13
14 async function go(endpoint: string, key: string) {
15 setLoading(key);
16 try {
17 const res = await fetch(endpoint, { method: "POST" });
18 const data = (await res.json()) as { url?: string; error?: string };
19 if (data.url) window.location.href = data.url;
20 } finally {
21 setLoading(null);
22 }
23 }
24
25 return (
26 <div className="mt-6 flex flex-wrap gap-3">
27 {hasCustomer && (
28 <button
29 onClick={() => go("/api/billing/portal", "portal")}
30 disabled={loading !== null}
31 className="rounded-lg bg-navy-700 px-4 py-2 text-sm font-semibold text-white hover:bg-navy-800 disabled:opacity-50"
32 >
33 {loading === "portal" ? "Loading..." : "Manage billing"}
34 </button>
35 )}
36 {!connectOnboarded && (
37 <button
38 onClick={() => go("/api/billing/connect/onboard", "connect")}
39 disabled={loading !== null}
40 className="rounded-lg border border-navy-300 bg-white px-4 py-2 text-sm font-semibold text-navy-700 hover:bg-navy-50 disabled:opacity-50"
41 >
42 {loading === "connect" ? "Loading..." : "Onboard with Stripe"}
43 </button>
44 )}
45 {connectOnboarded && (
46 <span className="rounded-lg bg-forest-100 px-4 py-2 text-sm font-semibold text-forest-800">
47 Connect onboarded
48 </span>
49 )}
50 </div>
51 );
52}
Addedapp/(platform)/billing/page.tsx+113−0View fileUnifiedSplit
@@ -0,0 +1,113 @@
1import { redirect } from "next/navigation";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { prisma } from "@/lib/prisma";
5import BillingActions from "./BillingActions";
6
7export const dynamic = "force-dynamic";
8
9function formatMoney(cents: number, currency: string) {
10 return new Intl.NumberFormat("en-US", {
11 style: "currency",
12 currency: currency.toUpperCase(),
13 }).format(cents / 100);
14}
15
16function StatusBadge({ status }: { status: string }) {
17 const colors: Record<string, string> = {
18 requires_capture: "bg-amber-100 text-amber-800",
19 captured: "bg-forest-100 text-forest-800",
20 refunded: "bg-navy-100 text-navy-700",
21 canceled: "bg-red-100 text-red-800",
22 };
23 const cls = colors[status] || "bg-navy-100 text-navy-700";
24 return (
25 <span className={`inline-block rounded px-2 py-0.5 text-xs font-semibold ${cls}`}>
26 {status}
27 </span>
28 );
29}
30
31export default async function BillingPage() {
32 const session = await getServerSession(authOptions);
33 const sessionUser = session?.user as { id?: string } | undefined;
34 if (!sessionUser?.id) {
35 redirect("/login?next=/billing");
36 }
37
38 const user = await prisma.user.findUnique({
39 where: { id: sessionUser.id },
40 });
41 if (!user) {
42 redirect("/login");
43 }
44
45 const payments = await prisma.marketplacePayment.findMany({
46 where: { professionalUserId: user.id },
47 orderBy: { createdAt: "desc" },
48 take: 20,
49 });
50
51 return (
52 <div className="mx-auto max-w-4xl px-6 py-12">
53 <h1 className="font-serif text-4xl text-navy-800">Billing</h1>
54
55 <section className="mt-10 rounded-2xl border border-navy-100 bg-white p-8 shadow-card">
56 <h2 className="text-xl font-semibold text-navy-800">Subscription</h2>
57 <dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
58 <div>
59 <dt className="text-navy-400">Status</dt>
60 <dd className="text-navy-700">{user.subscriptionStatus || "No active subscription"}</dd>
61 </div>
62 <div>
63 <dt className="text-navy-400">Plan</dt>
64 <dd className="text-navy-700">{user.stripePriceId || "—"}</dd>
65 </div>
66 <div>
67 <dt className="text-navy-400">Renews</dt>
68 <dd className="text-navy-700">
69 {user.subscriptionPeriodEnd
70 ? user.subscriptionPeriodEnd.toLocaleDateString()
71 : "—"}
72 </dd>
73 </div>
74 </dl>
75 <BillingActions
76 hasCustomer={Boolean(user.stripeCustomerId)}
77 connectOnboarded={user.connectOnboarded}
78 />
79 </section>
80
81 <section className="mt-10 rounded-2xl border border-navy-100 bg-white p-8 shadow-card">
82 <h2 className="text-xl font-semibold text-navy-800">
83 Marketplace payments received
84 </h2>
85 {payments.length === 0 ? (
86 <p className="mt-4 text-sm text-navy-400">No payments yet.</p>
87 ) : (
88 <ul className="mt-4 divide-y divide-navy-100">
89 {payments.map((p) => (
90 <li
91 key={p.id}
92 className="flex items-center justify-between py-3 text-sm"
93 >
94 <div>
95 <div className="font-semibold text-navy-700">
96 {formatMoney(p.amountCents, p.currency)}
97 </div>
98 <div className="text-navy-400">
99 {p.description || p.stripePaymentIntentId}
100 </div>
101 <div className="text-xs text-navy-400">
102 {p.createdAt.toLocaleString()}
103 </div>
104 </div>
105 <StatusBadge status={p.status} />
106 </li>
107 ))}
108 </ul>
109 )}
110 </section>
111 </div>
112 );
113}
Addedapp/(platform)/clients/new/page.tsx+126−0View fileUnifiedSplit
@@ -0,0 +1,126 @@
1"use client";
2
3import { useRouter } from "next/navigation";
4import { useState } from "react";
5
6export default function NewClientPage() {
7 const router = useRouter();
8 const [submitting, setSubmitting] = useState(false);
9 const [error, setError] = useState<string | null>(null);
10 const [form, setForm] = useState({
11 name: "",
12 email: "",
13 phone: "",
14 companyName: "",
15 address: "",
16 notes: "",
17 });
18
19 async function handleSubmit(e: React.FormEvent) {
20 e.preventDefault();
21 setSubmitting(true);
22 setError(null);
23 try {
24 const res = await fetch("/api/clients", {
25 method: "POST",
26 headers: { "Content-Type": "application/json" },
27 body: JSON.stringify(form),
28 });
29 if (!res.ok) {
30 const data = await res.json().catch(() => ({}));
31 throw new Error(data.error || "Failed to create client");
32 }
33 router.push("/clients");
34 router.refresh();
35 } catch (err) {
36 setError(err instanceof Error ? err.message : "Unknown error");
37 setSubmitting(false);
38 }
39 }
40
41 const input =
42 "w-full rounded-lg border border-navy-200 bg-white px-4 py-2.5 text-sm text-navy-700 placeholder:text-navy-300 focus:border-navy-500 focus:outline-none focus:ring-2 focus:ring-navy-100";
43 const label = "block text-sm font-medium text-navy-600 mb-1.5";
44
45 return (
46 <div className="mx-auto max-w-3xl px-6 py-12 sm:px-8 lg:px-12">
47 <h1 className="font-serif text-display text-navy-800">New client</h1>
48 <form
49 onSubmit={handleSubmit}
50 className="mt-8 space-y-5 rounded-2xl border border-navy-100 bg-white p-8 shadow-card"
51 >
52 <div>
53 <label className={label}>Name *</label>
54 <input
55 required
56 className={input}
57 value={form.name}
58 onChange={(e) => setForm({ ...form, name: e.target.value })}
59 />
60 </div>
61 <div>
62 <label className={label}>Email *</label>
63 <input
64 required
65 type="email"
66 className={input}
67 value={form.email}
68 onChange={(e) => setForm({ ...form, email: e.target.value })}
69 />
70 </div>
71 <div>
72 <label className={label}>Phone</label>
73 <input
74 className={input}
75 value={form.phone}
76 onChange={(e) => setForm({ ...form, phone: e.target.value })}
77 />
78 </div>
79 <div>
80 <label className={label}>Company</label>
81 <input
82 className={input}
83 value={form.companyName}
84 onChange={(e) => setForm({ ...form, companyName: e.target.value })}
85 />
86 </div>
87 <div>
88 <label className={label}>Address</label>
89 <input
90 className={input}
91 value={form.address}
92 onChange={(e) => setForm({ ...form, address: e.target.value })}
93 />
94 </div>
95 <div>
96 <label className={label}>Notes</label>
97 <textarea
98 rows={4}
99 className={input}
100 value={form.notes}
101 onChange={(e) => setForm({ ...form, notes: e.target.value })}
102 />
103 </div>
104
105 {error && <p className="text-sm text-plum-600">{error}</p>}
106
107 <div className="flex items-center gap-3">
108 <button
109 type="submit"
110 disabled={submitting}
111 className="inline-flex min-h-touch items-center justify-center rounded-lg bg-navy-500 px-7 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-navy-600 disabled:opacity-50"
112 >
113 {submitting ? "Creating..." : "Create client"}
114 </button>
115 <button
116 type="button"
117 onClick={() => router.back()}
118 className="inline-flex min-h-touch items-center rounded-lg px-5 py-3 text-sm font-semibold text-navy-500 hover:text-navy-700"
119 >
120 Cancel
121 </button>
122 </div>
123 </form>
124 </div>
125 );
126}
Addedapp/(platform)/clients/page.tsx+60−0View fileUnifiedSplit
@@ -0,0 +1,60 @@
1import Link from "next/link";
2import { redirect } from "next/navigation";
3import { prisma } from "@/lib/prisma";
4import { getUserId } from "@/lib/session";
5
6export const dynamic = "force-dynamic";
7
8export default async function ClientsPage() {
9 const userId = await getUserId();
10 if (!userId) redirect("/login");
11
12 const clients = await prisma.client.findMany({
13 where: { userId },
14 include: { _count: { select: { matters: true } } },
15 orderBy: { createdAt: "desc" },
16 });
17
18 return (
19 <div className="mx-auto max-w-6xl px-6 py-12 sm:px-8 lg:px-12">
20 <div className="flex items-center justify-between">
21 <h1 className="font-serif text-display text-navy-800">Clients</h1>
22 <Link
23 href="/clients/new"
24 className="inline-flex min-h-touch items-center justify-center rounded-lg bg-navy-500 px-7 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-navy-600"
25 >
26 Add client
27 </Link>
28 </div>
29
30 <div className="mt-8 overflow-hidden rounded-2xl border border-navy-100 bg-white shadow-card">
31 {clients.length === 0 ? (
32 <div className="p-8 text-center text-sm text-navy-400">
33 No clients yet. Add your first client to get started.
34 </div>
35 ) : (
36 <table className="w-full">
37 <thead className="border-b border-navy-100 bg-navy-50/50">
38 <tr className="text-left text-xs font-semibold uppercase tracking-wide text-navy-400">
39 <th className="px-6 py-3">Name</th>
40 <th className="px-6 py-3">Email</th>
41 <th className="px-6 py-3">Company</th>
42 <th className="px-6 py-3">Matters</th>
43 </tr>
44 </thead>
45 <tbody>
46 {clients.map((c) => (
47 <tr key={c.id} className="border-b border-navy-50 last:border-0">
48 <td className="px-6 py-4 text-sm font-medium text-navy-700">{c.name}</td>
49 <td className="px-6 py-4 text-sm text-navy-500">{c.email}</td>
50 <td className="px-6 py-4 text-sm text-navy-500">{c.companyName ?? "—"}</td>
51 <td className="px-6 py-4 text-sm text-navy-500">{c._count.matters}</td>
52 </tr>
53 ))}
54 </tbody>
55 </table>
56 )}
57 </div>
58 </div>
59 );
60}
Modifiedapp/(platform)/dashboard/page.tsx+98−8View fileUnifiedSplit
@@ -1,9 +1,94 @@
1"use client";
1import Link from "next/link";
2import { redirect } from "next/navigation";
3import { getServerSession } from "next-auth";
4import { authOptions } from "@/lib/auth";
5import { prisma } from "@/lib/prisma";
26
3import { useSession } from "next-auth/react";
7export const dynamic = "force-dynamic";
48
5export default function DashboardPage() {
6 const { data: session } = useSession();
9const accentRing: Record<string, string> = {
10 plum: "border-plum-200 hover:border-plum-400",
11 forest: "border-forest-200 hover:border-forest-400",
12 navy: "border-navy-100 hover:border-navy-300",
13};
14
15export default async function DashboardPage() {
16 const session = await getServerSession(authOptions);
17 const userId = (session?.user as { id?: string } | undefined)?.id;
18 if (!userId) redirect("/login");
19
20 const [matters, clients, documents, trustAccounts, timeEntries] =
21 await Promise.all([
22 prisma.matter.count({ where: { userId } }),
23 prisma.client.count({ where: { userId } }),
24 prisma.document.count({ where: { userId } }),
25 prisma.trustAccount.count({ where: { userId } }),
26 prisma.timeEntry.count({ where: { userId } }),
27 ]);
28
29 const cards: {
30 title: string;
31 desc: string;
32 href: string;
33 accent: "plum" | "forest" | "navy";
34 count?: string | null;
35 cta?: string;
36 }[] = [
37 {
38 title: "Marco",
39 desc: "Ask anything. Legal and accounting research with verified citations.",
40 href: "/marco",
41 accent: "plum",
42 cta: "Ask Marco",
43 },
44 {
45 title: "Voice",
46 desc: "Dictate anywhere. Powered by Marco Reid Voice.",
47 href: "/voice",
48 accent: "forest",
49 },
50 {
51 title: "Matters",
52 desc: "Manage your active cases and engagements.",
53 href: "/matters",
54 accent: "navy",
55 count: String(matters),
56 },
57 {
58 title: "Clients",
59 desc: "Your client directory and CRM.",
60 href: "/clients",
61 accent: "navy",
62 count: String(clients),
63 },
64 {
65 title: "Documents",
66 desc: "Files, drafts, and templates.",
67 href: "/documents",
68 accent: "navy",
69 count: String(documents),
70 },
71 {
72 title: "Trust",
73 desc: "IOLTA trust accounts and ledger.",
74 href: "/trust",
75 accent: "navy",
76 count: String(trustAccounts),
77 },
78 {
79 title: "Time",
80 desc: "Time tracking and billable entries.",
81 href: "/matters",
82 accent: "navy",
83 count: String(timeEntries),
84 },
85 {
86 title: "Billing",
87 desc: "Subscriptions, invoices, and marketplace payments.",
88 href: "/billing",
89 accent: "navy",
90 },
91 ];
792
893 return (
994 <div className="mx-auto max-w-6xl px-6 py-12 sm:px-8 lg:px-12">
@@ -25,18 +110,23 @@ export default function DashboardPage() {
25110 ].map((item) => (
26111 <div
27112 key={item.title}
28 className="rounded-2xl border border-navy-100 bg-white p-6 shadow-card transition-all duration-300 hover:shadow-card-hover hover:-translate-y-0.5"
113 href={item.href}
114 className={`group block rounded-2xl border bg-white p-6 shadow-card transition-all duration-300 hover:shadow-card-hover hover:-translate-y-0.5 ${accentRing[item.accent]}`}
29115 >
30116 <div className="flex items-center justify-between">
31117 <h2 className="font-semibold text-navy-700">{item.title}</h2>
32 {item.count !== null && (
118 {item.cta ? (
119 <span className="rounded-full bg-plum-50 px-2.5 py-0.5 text-xs font-medium text-plum-600">
120 {item.cta}
121 </span>
122 ) : item.count != null ? (
33123 <span className="rounded-full bg-navy-50 px-2.5 py-0.5 text-xs font-medium text-navy-400">
34124 {item.count}
35125 </span>
36 )}
126 ) : null}
37127 </div>
38128 <p className="mt-2 text-sm text-navy-400">{item.desc}</p>
39 </div>
129 </Link>
40130 ))}
41131 </div>
42132 </div>
Addedapp/(platform)/marco/page.tsx+21−0View fileUnifiedSplit
@@ -0,0 +1,21 @@
1import MarcoChat from "@/app/components/marco/MarcoChat";
2
3export const metadata = {
4 title: "Marco — Marco Reid",
5 description:
6 "Ask Marco anything. Cross-domain legal and accounting research with verified citations.",
7};
8
9export default function MarcoPage() {
10 return (
11 <div className="mx-auto max-w-5xl px-6 py-12 sm:px-8 lg:px-12">
12 <div className="mb-8">
13 <h1 className="font-serif text-display text-navy-800">Marco</h1>
14 <p className="mt-2 text-lg text-navy-400">
15 The greatest AI-generated mind for law and accountancy.
16 </p>
17 </div>
18 <MarcoChat />
19 </div>
20 );
21}
Addedapp/(platform)/matters/[id]/page.tsx+97−0View fileUnifiedSplit
@@ -0,0 +1,97 @@
1import Link from "next/link";
2import { notFound, redirect } from "next/navigation";
3import { prisma } from "@/lib/prisma";
4import { getUserId } from "@/lib/session";
5
6export const dynamic = "force-dynamic";
7
8const money = (cents: number) =>
9 new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
10
11export default async function MatterDetailPage({ params }: { params: { id: string } }) {
12 const userId = await getUserId();
13 if (!userId) redirect("/login");
14
15 const matter = await prisma.matter.findFirst({
16 where: { id: params.id, userId },
17 include: {
18 client: true,
19 documents: { orderBy: { createdAt: "desc" } },
20 timeEntries: { orderBy: { date: "desc" } },
21 },
22 });
23 if (!matter) notFound();
24
25 const billableCents = matter.timeEntries
26 .filter((t) => t.billable)
27 .reduce((sum, t) => sum + Math.round((t.minutes / 60) * t.rateInCents), 0);
28
29 return (
30 <div className="mx-auto max-w-6xl px-6 py-12 sm:px-8 lg:px-12">
31 <Link href="/matters" className="text-sm text-navy-400 hover:text-navy-600">
32 ← All matters
33 </Link>
34 <h1 className="mt-3 font-serif text-display text-navy-800">{matter.title}</h1>
35 <p className="mt-1 text-sm text-navy-400">
36 {matter.client.name}
37 {matter.practiceArea ? ` • ${matter.practiceArea}` : ""} • {matter.status}
38 </p>
39
40 {matter.description && (
41 <div className="mt-6 rounded-2xl border border-navy-100 bg-white p-6 shadow-card">
42 <h2 className="text-xs font-semibold uppercase tracking-wide text-navy-400">Description</h2>
43 <p className="mt-2 whitespace-pre-wrap text-sm text-navy-600">{matter.description}</p>
44 </div>
45 )}
46
47 <div className="mt-6 grid gap-6 lg:grid-cols-2">
48 <div className="rounded-2xl border border-navy-100 bg-white p-6 shadow-card">
49 <h2 className="font-semibold text-navy-700">Documents ({matter.documents.length})</h2>
50 {matter.documents.length === 0 ? (
51 <p className="mt-3 text-sm text-navy-400">No documents yet.</p>
52 ) : (
53 <ul className="mt-3 divide-y divide-navy-50">
54 {matter.documents.map((d) => (
55 <li key={d.id} className="py-3 text-sm">
56 <div className="font-medium text-navy-700">{d.title}</div>
57 <div className="text-xs text-navy-400">
58 {d.fileName} • {d.kind}
59 </div>
60 </li>
61 ))}
62 </ul>
63 )}
64 </div>
65
66 <div className="rounded-2xl border border-navy-100 bg-white p-6 shadow-card">
67 <div className="flex items-baseline justify-between">
68 <h2 className="font-semibold text-navy-700">
69 Time entries ({matter.timeEntries.length})
70 </h2>
71 <span className="text-sm font-semibold text-forest-600">
72 {money(billableCents)} billable
73 </span>
74 </div>
75 {matter.timeEntries.length === 0 ? (
76 <p className="mt-3 text-sm text-navy-400">No time entries yet.</p>
77 ) : (
78 <ul className="mt-3 divide-y divide-navy-50">
79 {matter.timeEntries.map((t) => (
80 <li key={t.id} className="py-3 text-sm">
81 <div className="flex justify-between">
82 <span className="font-medium text-navy-700">{t.description}</span>
83 <span className="text-navy-500">{(t.minutes / 60).toFixed(2)}h</span>
84 </div>
85 <div className="text-xs text-navy-400">
86 {new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(t.date)} •{" "}
87 {money(t.rateInCents)}/hr {t.billable ? "" : "• non-billable"}
88 </div>
89 </li>
90 ))}
91 </ul>
92 )}
93 </div>
94 </div>
95 </div>
96 );
97}
Addedapp/(platform)/matters/new/NewMatterForm.tsx+137−0View fileUnifiedSplit
@@ -0,0 +1,137 @@
1"use client";
2
3import { useRouter } from "next/navigation";
4import { useState } from "react";
5
6interface Props {
7 clients: { id: string; name: string }[];
8}
9
10export default function NewMatterForm({ clients }: Props) {
11 const router = useRouter();
12 const [submitting, setSubmitting] = useState(false);
13 const [error, setError] = useState<string | null>(null);
14 const [form, setForm] = useState({
15 clientId: clients[0]?.id ?? "",
16 title: "",
17 matterNumber: "",
18 practiceArea: "",
19 status: "ACTIVE",
20 description: "",
21 });
22
23 async function handleSubmit(e: React.FormEvent) {
24 e.preventDefault();
25 setSubmitting(true);
26 setError(null);
27 try {
28 const res = await fetch("/api/matters", {
29 method: "POST",
30 headers: { "Content-Type": "application/json" },
31 body: JSON.stringify(form),
32 });
33 if (!res.ok) {
34 const data = await res.json().catch(() => ({}));
35 throw new Error(data.error || "Failed to create matter");
36 }
37 router.push("/matters");
38 router.refresh();
39 } catch (err) {
40 setError(err instanceof Error ? err.message : "Unknown error");
41 setSubmitting(false);
42 }
43 }
44
45 const input =
46 "w-full rounded-lg border border-navy-200 bg-white px-4 py-2.5 text-sm text-navy-700 placeholder:text-navy-300 focus:border-navy-500 focus:outline-none focus:ring-2 focus:ring-navy-100";
47 const label = "block text-sm font-medium text-navy-600 mb-1.5";
48
49 return (
50 <form
51 onSubmit={handleSubmit}
52 className="mt-8 space-y-5 rounded-2xl border border-navy-100 bg-white p-8 shadow-card"
53 >
54 <div>
55 <label className={label}>Client *</label>
56 <select
57 required
58 className={input}
59 value={form.clientId}
60 onChange={(e) => setForm({ ...form, clientId: e.target.value })}
61 >
62 {clients.map((c) => (
63 <option key={c.id} value={c.id}>
64 {c.name}
65 </option>
66 ))}
67 </select>
68 </div>
69 <div>
70 <label className={label}>Title *</label>
71 <input
72 required
73 className={input}
74 value={form.title}
75 onChange={(e) => setForm({ ...form, title: e.target.value })}
76 />
77 </div>
78 <div>
79 <label className={label}>Matter number</label>
80 <input
81 className={input}
82 value={form.matterNumber}
83 onChange={(e) => setForm({ ...form, matterNumber: e.target.value })}
84 />
85 </div>
86 <div>
87 <label className={label}>Practice area</label>
88 <input
89 className={input}
90 placeholder="Immigration, Tax, Family..."
91 value={form.practiceArea}
92 onChange={(e) => setForm({ ...form, practiceArea: e.target.value })}
93 />
94 </div>
95 <div>
96 <label className={label}>Status</label>
97 <select
98 className={input}
99 value={form.status}
100 onChange={(e) => setForm({ ...form, status: e.target.value })}
101 >
102 <option value="ACTIVE">Active</option>
103 <option value="ON_HOLD">On hold</option>
104 <option value="CLOSED">Closed</option>
105 </select>
106 </div>
107 <div>
108 <label className={label}>Description</label>
109 <textarea
110 rows={4}
111 className={input}
112 value={form.description}
113 onChange={(e) => setForm({ ...form, description: e.target.value })}
114 />
115 </div>
116
117 {error && <p className="text-sm text-plum-600">{error}</p>}
118
119 <div className="flex items-center gap-3">
120 <button
121 type="submit"
122 disabled={submitting}
123 className="inline-flex min-h-touch items-center justify-center rounded-lg bg-navy-500 px-7 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-navy-600 disabled:opacity-50"
124 >
125 {submitting ? "Creating..." : "Create matter"}
126 </button>
127 <button
128 type="button"
129 onClick={() => router.back()}
130 className="inline-flex min-h-touch items-center rounded-lg px-5 py-3 text-sm font-semibold text-navy-500 hover:text-navy-700"
131 >
132 Cancel
133 </button>
134 </div>
135 </form>
136 );
137}
Addedapp/(platform)/matters/new/page.tsx+34−0View fileUnifiedSplit
@@ -0,0 +1,34 @@
1import { redirect } from "next/navigation";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4import NewMatterForm from "./NewMatterForm";
5
6export const dynamic = "force-dynamic";
7
8export default async function NewMatterPage() {
9 const userId = await getUserId();
10 if (!userId) redirect("/login");
11
12 const clients = await prisma.client.findMany({
13 where: { userId },
14 select: { id: true, name: true },
15 orderBy: { name: "asc" },
16 });
17
18 return (
19 <div className="mx-auto max-w-3xl px-6 py-12 sm:px-8 lg:px-12">
20 <h1 className="font-serif text-display text-navy-800">New matter</h1>
21 {clients.length === 0 ? (
22 <p className="mt-6 text-sm text-navy-400">
23 You need to add a client first.{" "}
24 <a href="/clients/new" className="text-navy-600 underline">
25 Add a client
26 </a>
27 .
28 </p>
29 ) : (
30 <NewMatterForm clients={clients} />
31 )}
32 </div>
33 );
34}
Addedapp/(platform)/matters/page.tsx+80−0View fileUnifiedSplit
@@ -0,0 +1,80 @@
1import Link from "next/link";
2import { redirect } from "next/navigation";
3import { prisma } from "@/lib/prisma";
4import { getUserId } from "@/lib/session";
5
6export const dynamic = "force-dynamic";
7
8const statusStyles: Record<string, string> = {
9 ACTIVE: "bg-forest-50 text-forest-600",
10 ON_HOLD: "bg-navy-50 text-navy-500",
11 CLOSED: "bg-plum-50 text-plum-600",
12};
13
14export default async function MattersPage() {
15 const userId = await getUserId();
16 if (!userId) redirect("/login");
17
18 const matters = await prisma.matter.findMany({
19 where: { userId },
20 include: { client: { select: { id: true, name: true } } },
21 orderBy: { openedAt: "desc" },
22 });
23
24 return (
25 <div className="mx-auto max-w-6xl px-6 py-12 sm:px-8 lg:px-12">
26 <div className="flex items-center justify-between">
27 <h1 className="font-serif text-display text-navy-800">Matters</h1>
28 <Link
29 href="/matters/new"
30 className="inline-flex min-h-touch items-center justify-center rounded-lg bg-navy-500 px-7 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-navy-600"
31 >
32 New matter
33 </Link>
34 </div>
35
36 <div className="mt-8 overflow-hidden rounded-2xl border border-navy-100 bg-white shadow-card">
37 {matters.length === 0 ? (
38 <div className="p-8 text-center text-sm text-navy-400">
39 No matters yet. Open your first matter to begin.
40 </div>
41 ) : (
42 <table className="w-full">
43 <thead className="border-b border-navy-100 bg-navy-50/50">
44 <tr className="text-left text-xs font-semibold uppercase tracking-wide text-navy-400">
45 <th className="px-6 py-3">Title</th>
46 <th className="px-6 py-3">Client</th>
47 <th className="px-6 py-3">Status</th>
48 <th className="px-6 py-3">Opened</th>
49 </tr>
50 </thead>
51 <tbody>
52 {matters.map((m) => (
53 <tr key={m.id} className="border-b border-navy-50 last:border-0">
54 <td className="px-6 py-4 text-sm font-medium text-navy-700">
55 <Link href={`/matters/${m.id}`} className="hover:text-navy-900">
56 {m.title}
57 </Link>
58 </td>
59 <td className="px-6 py-4 text-sm text-navy-500">{m.client.name}</td>
60 <td className="px-6 py-4">
61 <span
62 className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
63 statusStyles[m.status] ?? "bg-navy-50 text-navy-500"
64 }`}
65 >
66 {m.status}
67 </span>
68 </td>
69 <td className="px-6 py-4 text-sm text-navy-500">
70 {new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(m.openedAt)}
71 </td>
72 </tr>
73 ))}
74 </tbody>
75 </table>
76 )}
77 </div>
78 </div>
79 );
80}
Addedapp/(platform)/trust/page.tsx+55−0View fileUnifiedSplit
@@ -0,0 +1,55 @@
1import { redirect } from "next/navigation";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4
5export const dynamic = "force-dynamic";
6
7export default async function TrustPage() {
8 const userId = await getUserId();
9 if (!userId) redirect("/login");
10
11 const accounts = await prisma.trustAccount.findMany({
12 where: { userId },
13 include: { client: { select: { id: true, name: true } } },
14 orderBy: { createdAt: "desc" },
15 });
16
17 const money = (cents: number, currency: string) =>
18 new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
19
20 return (
21 <div className="mx-auto max-w-6xl px-6 py-12 sm:px-8 lg:px-12">
22 <h1 className="font-serif text-display text-navy-800">Trust accounts</h1>
23 <p className="mt-2 text-sm text-navy-400">
24 Client trust balances. Every deposit, withdrawal, and fee draw is recorded.
25 </p>
26
27 <div className="mt-8 overflow-hidden rounded-2xl border border-navy-100 bg-white shadow-card">
28 {accounts.length === 0 ? (
29 <div className="p-8 text-center text-sm text-navy-400">No trust accounts yet.</div>
30 ) : (
31 <table className="w-full">
32 <thead className="border-b border-navy-100 bg-navy-50/50">
33 <tr className="text-left text-xs font-semibold uppercase tracking-wide text-navy-400">
34 <th className="px-6 py-3">Client</th>
35 <th className="px-6 py-3">Currency</th>
36 <th className="px-6 py-3 text-right">Balance</th>
37 </tr>
38 </thead>
39 <tbody>
40 {accounts.map((a) => (
41 <tr key={a.id} className="border-b border-navy-50 last:border-0">
42 <td className="px-6 py-4 text-sm font-medium text-navy-700">{a.client.name}</td>
43 <td className="px-6 py-4 text-sm text-navy-500">{a.currency}</td>
44 <td className="px-6 py-4 text-right text-sm font-semibold text-forest-600">
45 {money(a.balanceInCents, a.currency)}
46 </td>
47 </tr>
48 ))}
49 </tbody>
50 </table>
51 )}
52 </div>
53 </div>
54 );
55}
Addedapp/(platform)/voice/VoiceDictationClient.tsx+112−0View fileUnifiedSplit
@@ -0,0 +1,112 @@
1"use client";
2
3import { useState } from "react";
4import VoiceRecorder from "@/app/components/voice/VoiceRecorder";
5
6export interface TranscriptItem {
7 id: string;
8 text: string;
9 language: string | null;
10 durationMs: number | null;
11 createdAt: string;
12}
13
14interface Props {
15 initialTranscripts: TranscriptItem[];
16}
17
18export default function VoiceDictationClient({ initialTranscripts }: Props) {
19 const [transcripts, setTranscripts] = useState<TranscriptItem[]>(initialTranscripts);
20 const [latestText, setLatestText] = useState<string>(
21 initialTranscripts[0]?.text ?? ""
22 );
23 const [copied, setCopied] = useState(false);
24
25 async function refreshList() {
26 try {
27 const res = await fetch("/api/voice/transcripts", { cache: "no-store" });
28 if (!res.ok) return;
29 const data = (await res.json()) as { transcripts: TranscriptItem[] };
30 setTranscripts(data.transcripts);
31 } catch {
32 // ignore
33 }
34 }
35
36 function handleTranscript(text: string) {
37 setLatestText(text);
38 refreshList();
39 }
40
41 async function copyLatest() {
42 if (!latestText) return;
43 try {
44 await navigator.clipboard.writeText(latestText);
45 setCopied(true);
46 setTimeout(() => setCopied(false), 1500);
47 } catch {
48 // ignore
49 }
50 }
51
52 return (
53 <div className="space-y-10">
54 <VoiceRecorder
55 onTranscript={handleTranscript}
56 surface="dashboard"
57 placeholder="Dictate anywhere. Your words become text."
58 />
59
60 <div>
61 <div className="flex items-center justify-between">
62 <h2 className="font-serif text-2xl text-navy-700">Latest transcript</h2>
63 <button
64 type="button"
65 onClick={copyLatest}
66 disabled={!latestText}
67 className="inline-flex min-h-touch items-center justify-center rounded-full border border-navy-200 bg-white px-4 py-1.5 text-sm font-medium text-navy-700 transition-colors hover:bg-navy-50 disabled:cursor-not-allowed disabled:opacity-40"
68 >
69 {copied ? "Copied" : "Copy"}
70 </button>
71 </div>
72 <textarea
73 value={latestText}
74 onChange={(e) => setLatestText(e.target.value)}
75 placeholder="Your transcribed text will appear here…"
76 className="mt-4 h-48 w-full rounded-2xl border border-navy-100 bg-white p-5 text-base leading-relaxed text-navy-700 shadow-card focus:border-forest-400 focus:outline-none"
77 />
78 </div>
79
80 <div>
81 <h2 className="font-serif text-2xl text-navy-700">Recent dictations</h2>
82 {transcripts.length === 0 ? (
83 <p className="mt-4 text-sm text-navy-400">
84 No dictations yet. Start recording above.
85 </p>
86 ) : (
87 <ul className="mt-4 space-y-3">
88 {transcripts.slice(0, 10).map((t) => (
89 <li
90 key={t.id}
91 className="rounded-xl border border-navy-100 bg-white p-4 shadow-card"
92 >
93 <div className="flex items-center justify-between text-xs text-navy-400">
94 <span>{new Date(t.createdAt).toLocaleString()}</span>
95 <span>
96 {t.language ? t.language.toUpperCase() : ""}
97 {t.durationMs
98 ? ` · ${(t.durationMs / 1000).toFixed(1)}s`
99 : ""}
100 </span>
101 </div>
102 <p className="mt-2 text-sm leading-relaxed text-navy-700 line-clamp-3">
103 {t.text}
104 </p>
105 </li>
106 ))}
107 </ul>
108 )}
109 </div>
110 </div>
111 );
112}
Addedapp/(platform)/voice/page.tsx+55−0View fileUnifiedSplit
@@ -0,0 +1,55 @@
1import { redirect } from "next/navigation";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { prisma } from "@/lib/prisma";
5import Container from "@/app/components/shared/Container";
6import VoiceDictationClient, {
7 type TranscriptItem,
8} from "./VoiceDictationClient";
9
10export const metadata = {
11 title: "Marco Reid Voice — Dictate anywhere",
12};
13
14export default async function VoicePage() {
15 const session = await getServerSession(authOptions);
16
17 if (!session?.user) {
18 redirect("/login");
19 }
20
21 const userId = (session.user as unknown as { id: string }).id;
22
23 const rows = await prisma.voiceTranscript.findMany({
24 where: { userId },
25 orderBy: { createdAt: "desc" },
26 take: 50,
27 });
28
29 const initialTranscripts: TranscriptItem[] = rows.map((r) => ({
30 id: r.id,
31 text: r.text,
32 language: r.language,
33 durationMs: r.durationMs,
34 createdAt: r.createdAt.toISOString(),
35 }));
36
37 return (
38 <Container className="py-12">
39 <div className="mb-10">
40 <p className="text-xs font-medium uppercase tracking-widest text-navy-500">
41 Marco Reid Voice
42 </p>
43 <h1 className="mt-4 font-serif text-display text-navy-800">
44 Speak. It is done.
45 </h1>
46 <p className="mt-3 max-w-2xl text-lg text-navy-400">
47 Universal speech-to-text dictation for legal and accounting
48 professionals. Record, transcribe, and paste anywhere.
49 </p>
50 </div>
51
52 <VoiceDictationClient initialTranscripts={initialTranscripts} />
53 </Container>
54 );
55}
Addedapp/api/billing/checkout/route.ts+27−0View fileUnifiedSplit
@@ -0,0 +1,27 @@
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { createCheckoutSession } from "@/lib/stripe";
5
6export async function POST(req: Request) {
7 const session = await getServerSession(authOptions);
8 const userId = (session?.user as { id?: string } | undefined)?.id;
9 if (!userId) {
10 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11 }
12
13 const { priceId } = (await req.json()) as { priceId?: string };
14 if (!priceId) {
15 return NextResponse.json({ error: "priceId required" }, { status: 400 });
16 }
17
18 const base = process.env.NEXTAUTH_URL || "http://localhost:3000";
19 const checkout = await createCheckoutSession({
20 userId,
21 priceId,
22 successUrl: `${base}/dashboard/billing?success=true`,
23 cancelUrl: `${base}/pricing`,
24 });
25
26 return NextResponse.json({ url: checkout.url });
27}
Addedapp/api/billing/connect/onboard/route.ts+21−0View fileUnifiedSplit
@@ -0,0 +1,21 @@
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { createConnectAccountLink } from "@/lib/stripe";
5
6export async function POST() {
7 const session = await getServerSession(authOptions);
8 const userId = (session?.user as { id?: string } | undefined)?.id;
9 if (!userId) {
10 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11 }
12
13 const base = process.env.NEXTAUTH_URL || "http://localhost:3000";
14 const { url } = await createConnectAccountLink({
15 userId,
16 returnUrl: `${base}/dashboard/billing?connect=done`,
17 refreshUrl: `${base}/dashboard/billing?connect=refresh`,
18 });
19
20 return NextResponse.json({ url });
21}
Addedapp/api/billing/portal/route.ts+26−0View fileUnifiedSplit
@@ -0,0 +1,26 @@
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { prisma } from "@/lib/prisma";
5import { createBillingPortalSession } from "@/lib/stripe";
6
7export async function POST() {
8 const session = await getServerSession(authOptions);
9 const userId = (session?.user as { id?: string } | undefined)?.id;
10 if (!userId) {
11 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
12 }
13
14 const user = await prisma.user.findUnique({ where: { id: userId } });
15 if (!user?.stripeCustomerId) {
16 return NextResponse.json({ error: "No Stripe customer" }, { status: 400 });
17 }
18
19 const base = process.env.NEXTAUTH_URL || "http://localhost:3000";
20 const portal = await createBillingPortalSession({
21 customerId: user.stripeCustomerId,
22 returnUrl: `${base}/dashboard/billing`,
23 });
24
25 return NextResponse.json({ url: portal.url });
26}
Addedapp/api/clients/[id]/route.ts+53−0View fileUnifiedSplit
@@ -0,0 +1,53 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4
5export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
6 const userId = await getUserId();
7 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8
9 try {
10 const client = await prisma.client.findFirst({
11 where: { id: params.id, userId },
12 include: { matters: true },
13 });
14 if (!client) return NextResponse.json({ error: "Not found" }, { status: 404 });
15 return NextResponse.json({ client });
16 } catch {
17 return NextResponse.json({ error: "Internal error" }, { status: 500 });
18 }
19}
20
21export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
22 const userId = await getUserId();
23 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
24
25 try {
26 const existing = await prisma.client.findFirst({ where: { id: params.id, userId } });
27 if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
28
29 const body = await req.json();
30 const { name, email, phone, address, companyName, notes } = body ?? {};
31 const client = await prisma.client.update({
32 where: { id: params.id },
33 data: { name, email, phone, address, companyName, notes },
34 });
35 return NextResponse.json({ client });
36 } catch {
37 return NextResponse.json({ error: "Internal error" }, { status: 500 });
38 }
39}
40
41export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
42 const userId = await getUserId();
43 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
44
45 try {
46 const existing = await prisma.client.findFirst({ where: { id: params.id, userId } });
47 if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
48 await prisma.client.delete({ where: { id: params.id } });
49 return NextResponse.json({ ok: true });
50 } catch {
51 return NextResponse.json({ error: "Internal error" }, { status: 500 });
52 }
53}
Addedapp/api/clients/route.ts+38−0View fileUnifiedSplit
@@ -0,0 +1,38 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4
5export async function GET() {
6 const userId = await getUserId();
7 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8
9 try {
10 const clients = await prisma.client.findMany({
11 where: { userId },
12 include: { _count: { select: { matters: true } } },
13 orderBy: { createdAt: "desc" },
14 });
15 return NextResponse.json({ clients });
16 } catch {
17 return NextResponse.json({ error: "Internal error" }, { status: 500 });
18 }
19}
20
21export async function POST(req: NextRequest) {
22 const userId = await getUserId();
23 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
24
25 try {
26 const body = await req.json();
27 const { name, email, phone, address, companyName, notes } = body ?? {};
28 if (!name || !email) {
29 return NextResponse.json({ error: "name and email required" }, { status: 400 });
30 }
31 const client = await prisma.client.create({
32 data: { userId, name, email, phone, address, companyName, notes },
33 });
34 return NextResponse.json({ client }, { status: 201 });
35 } catch {
36 return NextResponse.json({ error: "Internal error" }, { status: 500 });
37 }
38}
Addedapp/api/courts/pilot/route.ts+31−0View fileUnifiedSplit
@@ -0,0 +1,31 @@
1import { NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3
4export async function POST(req: Request) {
5 try {
6 const body = await req.json();
7 const { name, role, court, jurisdiction, email, phone, products, useCase } = body ?? {};
8
9 if (!name || !role || !court || !jurisdiction || !email || !useCase) {
10 return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
11 }
12
13 await prisma.courtPilotRequest.create({
14 data: {
15 name,
16 role,
17 court,
18 jurisdiction,
19 email,
20 phone: phone ?? null,
21 products: Array.isArray(products) ? products : [],
22 useCase,
23 },
24 });
25
26 return NextResponse.json({ ok: true });
27 } catch (err) {
28 console.error("[courts/pilot] error", err);
29 return NextResponse.json({ error: "Submission failed" }, { status: 500 });
30 }
31}
Addedapp/api/documents/[id]/route.ts+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4
5export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
6 const userId = await getUserId();
7 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8
9 try {
10 const document = await prisma.document.findFirst({
11 where: { id: params.id, userId },
12 include: { matter: true, client: true },
13 });
14 if (!document) return NextResponse.json({ error: "Not found" }, { status: 404 });
15 return NextResponse.json({ document });
16 } catch {
17 return NextResponse.json({ error: "Internal error" }, { status: 500 });
18 }
19}
20
21export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
22 const userId = await getUserId();
23 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
24
25 try {
26 const existing = await prisma.document.findFirst({ where: { id: params.id, userId } });
27 if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
28 await prisma.document.delete({ where: { id: params.id } });
29 return NextResponse.json({ ok: true });
30 } catch {
31 return NextResponse.json({ error: "Internal error" }, { status: 500 });
32 }
33}
Addedapp/api/documents/route.ts+65−0View fileUnifiedSplit
@@ -0,0 +1,65 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4import { DocumentKind } from "@prisma/client";
5
6export async function GET() {
7 const userId = await getUserId();
8 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
9
10 try {
11 const documents = await prisma.document.findMany({
12 where: { userId },
13 include: {
14 matter: { select: { id: true, title: true } },
15 client: { select: { id: true, name: true } },
16 },
17 orderBy: { createdAt: "desc" },
18 });
19 return NextResponse.json({ documents });
20 } catch {
21 return NextResponse.json({ error: "Internal error" }, { status: 500 });
22 }
23}
24
25export async function POST(req: NextRequest) {
26 const userId = await getUserId();
27 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
28
29 try {
30 const body = await req.json();
31 const { title, fileName, fileUrl, fileSize, mimeType, kind, matterId, clientId } = body ?? {};
32 if (!title || !fileName || !fileUrl || typeof fileSize !== "number" || !mimeType) {
33 return NextResponse.json(
34 { error: "title, fileName, fileUrl, fileSize, mimeType required" },
35 { status: 400 }
36 );
37 }
38
39 if (matterId) {
40 const m = await prisma.matter.findFirst({ where: { id: matterId, userId } });
41 if (!m) return NextResponse.json({ error: "Invalid matter" }, { status: 400 });
42 }
43 if (clientId) {
44 const c = await prisma.client.findFirst({ where: { id: clientId, userId } });
45 if (!c) return NextResponse.json({ error: "Invalid client" }, { status: 400 });
46 }
47
48 const document = await prisma.document.create({
49 data: {
50 userId,
51 title,
52 fileName,
53 fileUrl,
54 fileSize,
55 mimeType,
56 kind: (kind as DocumentKind) || DocumentKind.OTHER,
57 matterId: matterId || null,
58 clientId: clientId || null,
59 },
60 });
61 return NextResponse.json({ document }, { status: 201 });
62 } catch {
63 return NextResponse.json({ error: "Internal error" }, { status: 500 });
64 }
65}
Addedapp/api/marketplace/checkout/route.ts+43−0View fileUnifiedSplit
@@ -0,0 +1,43 @@
1import { NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { createMarketplaceCheckoutSession } from "@/lib/stripe";
4
5export async function POST(req: Request) {
6 const body = (await req.json()) as {
7 professionalUserId?: string;
8 amountCents?: number;
9 description?: string;
10 matterId?: string;
11 customerEmail?: string;
12 };
13
14 if (!body.professionalUserId || !body.amountCents || !body.description) {
15 return NextResponse.json({ error: "Missing fields" }, { status: 400 });
16 }
17
18 const professional = await prisma.user.findUnique({
19 where: { id: body.professionalUserId },
20 });
21 if (!professional?.stripeConnectAccountId || !professional.connectOnboarded) {
22 return NextResponse.json(
23 { error: "Professional not onboarded to Stripe Connect" },
24 { status: 400 },
25 );
26 }
27
28 const applicationFeeCents = Math.round(body.amountCents * 0.1);
29
30 const checkout = await createMarketplaceCheckoutSession({
31 amountCents: body.amountCents,
32 professionalConnectAccountId: professional.stripeConnectAccountId,
33 customerEmail: body.customerEmail,
34 description: body.description,
35 applicationFeeCents,
36 metadata: {
37 professionalUserId: professional.id,
38 matterId: body.matterId || "",
39 },
40 });
41
42 return NextResponse.json({ url: checkout.url });
43}
Addedapp/api/marketplace/payments/[id]/capture/route.ts+39−0View fileUnifiedSplit
@@ -0,0 +1,39 @@
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { prisma } from "@/lib/prisma";
5import { stripe } from "@/lib/stripe";
6
7export async function POST(
8 _req: Request,
9 { params }: { params: { id: string } },
10) {
11 const session = await getServerSession(authOptions);
12 const sessionUser = session?.user as { id?: string; role?: string } | undefined;
13 if (!sessionUser?.id) {
14 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
15 }
16
17 const payment = await prisma.marketplacePayment.findUnique({
18 where: { id: params.id },
19 });
20 if (!payment) {
21 return NextResponse.json({ error: "Not found" }, { status: 404 });
22 }
23
24 if (
25 payment.professionalUserId !== sessionUser.id &&
26 sessionUser.role !== "ADMIN"
27 ) {
28 return NextResponse.json({ error: "Forbidden" }, { status: 403 });
29 }
30
31 await stripe.paymentIntents.capture(payment.stripePaymentIntentId);
32
33 const updated = await prisma.marketplacePayment.update({
34 where: { id: payment.id },
35 data: { status: "captured", capturedAt: new Date() },
36 });
37
38 return NextResponse.json({ payment: updated });
39}
Addedapp/api/marketplace/payments/[id]/refund/route.ts+50−0View fileUnifiedSplit
@@ -0,0 +1,50 @@
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { prisma } from "@/lib/prisma";
5import { stripe } from "@/lib/stripe";
6
7export async function POST(
8 _req: Request,
9 { params }: { params: { id: string } },
10) {
11 const session = await getServerSession(authOptions);
12 const sessionUser = session?.user as { id?: string; role?: string } | undefined;
13 if (!sessionUser?.id) {
14 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
15 }
16
17 const payment = await prisma.marketplacePayment.findUnique({
18 where: { id: params.id },
19 });
20 if (!payment) {
21 return NextResponse.json({ error: "Not found" }, { status: 404 });
22 }
23
24 if (
25 payment.professionalUserId !== sessionUser.id &&
26 sessionUser.role !== "ADMIN"
27 ) {
28 return NextResponse.json({ error: "Forbidden" }, { status: 403 });
29 }
30
31 if (payment.status === "requires_capture") {
32 await stripe.paymentIntents.cancel(payment.stripePaymentIntentId);
33 const updated = await prisma.marketplacePayment.update({
34 where: { id: payment.id },
35 data: { status: "canceled" },
36 });
37 return NextResponse.json({ payment: updated });
38 }
39
40 await stripe.refunds.create({
41 payment_intent: payment.stripePaymentIntentId,
42 });
43
44 const updated = await prisma.marketplacePayment.update({
45 where: { id: payment.id },
46 data: { status: "refunded" },
47 });
48
49 return NextResponse.json({ payment: updated });
50}
Addedapp/api/matters/[id]/route.ts+65−0View fileUnifiedSplit
@@ -0,0 +1,65 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4import { MatterStatus } from "@prisma/client";
5
6export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
7 const userId = await getUserId();
8 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
9
10 try {
11 const matter = await prisma.matter.findFirst({
12 where: { id: params.id, userId },
13 include: {
14 client: true,
15 documents: true,
16 timeEntries: { orderBy: { date: "desc" } },
17 },
18 });
19 if (!matter) return NextResponse.json({ error: "Not found" }, { status: 404 });
20 return NextResponse.json({ matter });
21 } catch {
22 return NextResponse.json({ error: "Internal error" }, { status: 500 });
23 }
24}
25
26export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
27 const userId = await getUserId();
28 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
29
30 try {
31 const existing = await prisma.matter.findFirst({ where: { id: params.id, userId } });
32 if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
33
34 const body = await req.json();
35 const { title, matterNumber, practiceArea, status, description, closedAt } = body ?? {};
36 const matter = await prisma.matter.update({
37 where: { id: params.id },
38 data: {
39 title,
40 matterNumber,
41 practiceArea,
42 status: status as MatterStatus | undefined,
43 description,
44 closedAt: closedAt ? new Date(closedAt) : undefined,
45 },
46 });
47 return NextResponse.json({ matter });
48 } catch {
49 return NextResponse.json({ error: "Internal error" }, { status: 500 });
50 }
51}
52
53export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
54 const userId = await getUserId();
55 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
56
57 try {
58 const existing = await prisma.matter.findFirst({ where: { id: params.id, userId } });
59 if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
60 await prisma.matter.delete({ where: { id: params.id } });
61 return NextResponse.json({ ok: true });
62 } catch {
63 return NextResponse.json({ error: "Internal error" }, { status: 500 });
64 }
65}
Addedapp/api/matters/route.ts+52−0View fileUnifiedSplit
@@ -0,0 +1,52 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4import { MatterStatus } from "@prisma/client";
5
6export async function GET(req: NextRequest) {
7 const userId = await getUserId();
8 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
9
10 try {
11 const clientId = req.nextUrl.searchParams.get("clientId") ?? undefined;
12 const matters = await prisma.matter.findMany({
13 where: { userId, ...(clientId ? { clientId } : {}) },
14 include: { client: { select: { id: true, name: true } } },
15 orderBy: { openedAt: "desc" },
16 });
17 return NextResponse.json({ matters });
18 } catch {
19 return NextResponse.json({ error: "Internal error" }, { status: 500 });
20 }
21}
22
23export async function POST(req: NextRequest) {
24 const userId = await getUserId();
25 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
26
27 try {
28 const body = await req.json();
29 const { clientId, title, matterNumber, practiceArea, status, description } = body ?? {};
30 if (!clientId || !title) {
31 return NextResponse.json({ error: "clientId and title required" }, { status: 400 });
32 }
33 // Verify client belongs to this user
34 const client = await prisma.client.findFirst({ where: { id: clientId, userId } });
35 if (!client) return NextResponse.json({ error: "Invalid client" }, { status: 400 });
36
37 const matter = await prisma.matter.create({
38 data: {
39 userId,
40 clientId,
41 title,
42 matterNumber: matterNumber || null,
43 practiceArea: practiceArea || null,
44 status: (status as MatterStatus) || MatterStatus.ACTIVE,
45 description: description || null,
46 },
47 });
48 return NextResponse.json({ matter }, { status: 201 });
49 } catch {
50 return NextResponse.json({ error: "Internal error" }, { status: 500 });
51 }
52}
Addedapp/api/time-entries/route.ts+59−0View fileUnifiedSplit
@@ -0,0 +1,59 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4
5export async function GET(req: NextRequest) {
6 const userId = await getUserId();
7 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8
9 try {
10 const matterId = req.nextUrl.searchParams.get("matterId") ?? undefined;
11 const timeEntries = await prisma.timeEntry.findMany({
12 where: { userId, ...(matterId ? { matterId } : {}) },
13 include: { matter: { select: { id: true, title: true } } },
14 orderBy: { date: "desc" },
15 });
16 return NextResponse.json({ timeEntries });
17 } catch {
18 return NextResponse.json({ error: "Internal error" }, { status: 500 });
19 }
20}
21
22export async function POST(req: NextRequest) {
23 const userId = await getUserId();
24 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
25
26 try {
27 const body = await req.json();
28 const { matterId, description, minutes, rateInCents, date, billable } = body ?? {};
29 if (
30 !matterId ||
31 !description ||
32 typeof minutes !== "number" ||
33 typeof rateInCents !== "number" ||
34 !date
35 ) {
36 return NextResponse.json(
37 { error: "matterId, description, minutes, rateInCents, date required" },
38 { status: 400 }
39 );
40 }
41 const matter = await prisma.matter.findFirst({ where: { id: matterId, userId } });
42 if (!matter) return NextResponse.json({ error: "Invalid matter" }, { status: 400 });
43
44 const entry = await prisma.timeEntry.create({
45 data: {
46 userId,
47 matterId,
48 description,
49 minutes,
50 rateInCents,
51 date: new Date(date),
52 billable: billable ?? true,
53 },
54 });
55 return NextResponse.json({ timeEntry: entry }, { status: 201 });
56 } catch {
57 return NextResponse.json({ error: "Internal error" }, { status: 500 });
58 }
59}
Addedapp/api/trust-accounts/[id]/transactions/route.ts+82−0View fileUnifiedSplit
@@ -0,0 +1,82 @@
1import { NextRequest, NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4import { TrustTransactionType } from "@prisma/client";
5
6export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
7 const userId = await getUserId();
8 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
9
10 try {
11 const account = await prisma.trustAccount.findFirst({
12 where: { id: params.id, userId },
13 });
14 if (!account) return NextResponse.json({ error: "Not found" }, { status: 404 });
15
16 const transactions = await prisma.trustTransaction.findMany({
17 where: { trustAccountId: account.id },
18 orderBy: { createdAt: "desc" },
19 });
20 return NextResponse.json({ transactions, balanceInCents: account.balanceInCents });
21 } catch {
22 return NextResponse.json({ error: "Internal error" }, { status: 500 });
23 }
24}
25
26export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
27 const userId = await getUserId();
28 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
29
30 try {
31 const account = await prisma.trustAccount.findFirst({
32 where: { id: params.id, userId },
33 });
34 if (!account) return NextResponse.json({ error: "Not found" }, { status: 404 });
35
36 const body = await req.json();
37 const { type, amountInCents, description, matterId } = body ?? {};
38 if (!type || typeof amountInCents !== "number" || !description) {
39 return NextResponse.json(
40 { error: "type, amountInCents, description required" },
41 { status: 400 }
42 );
43 }
44 if (!Object.values(TrustTransactionType).includes(type)) {
45 return NextResponse.json({ error: "Invalid type" }, { status: 400 });
46 }
47 if (amountInCents <= 0) {
48 return NextResponse.json({ error: "amountInCents must be positive" }, { status: 400 });
49 }
50
51 const delta = type === TrustTransactionType.DEPOSIT ? amountInCents : -amountInCents;
52 const newBalance = account.balanceInCents + delta;
53 if (newBalance < 0) {
54 return NextResponse.json({ error: "Insufficient trust balance" }, { status: 400 });
55 }
56
57 if (matterId) {
58 const m = await prisma.matter.findFirst({ where: { id: matterId, userId } });
59 if (!m) return NextResponse.json({ error: "Invalid matter" }, { status: 400 });
60 }
61
62 const [transaction] = await prisma.$transaction([
63 prisma.trustTransaction.create({
64 data: {
65 trustAccountId: account.id,
66 type: type as TrustTransactionType,
67 amountInCents,
68 description,
69 matterId: matterId || null,
70 },
71 }),
72 prisma.trustAccount.update({
73 where: { id: account.id },
74 data: { balanceInCents: newBalance },
75 }),
76 ]);
77
78 return NextResponse.json({ transaction, balanceInCents: newBalance }, { status: 201 });
79 } catch {
80 return NextResponse.json({ error: "Internal error" }, { status: 500 });
81 }
82}
Addedapp/api/trust-accounts/route.ts+19−0View fileUnifiedSplit
@@ -0,0 +1,19 @@
1import { NextResponse } from "next/server";
2import { prisma } from "@/lib/prisma";
3import { getUserId } from "@/lib/session";
4
5export async function GET() {
6 const userId = await getUserId();
7 if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
8
9 try {
10 const accounts = await prisma.trustAccount.findMany({
11 where: { userId },
12 include: { client: { select: { id: true, name: true } } },
13 orderBy: { createdAt: "desc" },
14 });
15 return NextResponse.json({ trustAccounts: accounts });
16 } catch {
17 return NextResponse.json({ error: "Internal error" }, { status: 500 });
18 }
19}
Addedapp/api/voice/transcribe/route.ts+83−0View fileUnifiedSplit
@@ -0,0 +1,83 @@
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import OpenAI from "openai";
4import { authOptions } from "@/lib/auth";
5import { prisma } from "@/lib/prisma";
6
7export const runtime = "nodejs";
8
9export async function POST(request: Request) {
10 try {
11 const session = await getServerSession(authOptions);
12
13 if (!session?.user) {
14 return NextResponse.json(
15 { error: "Authentication required." },
16 { status: 401 }
17 );
18 }
19
20 const userId = (session.user as unknown as { id: string }).id;
21
22 const formData = await request.formData();
23 const audio = formData.get("audio");
24 const language = formData.get("language");
25 const surface = formData.get("surface");
26 const matterId = formData.get("matterId");
27
28 if (!audio || !(audio instanceof File)) {
29 return NextResponse.json(
30 { error: "Missing audio file." },
31 { status: 400 }
32 );
33 }
34
35 if (!process.env.OPENAI_API_KEY) {
36 return NextResponse.json(
37 { error: "Voice transcription is not configured." },
38 { status: 500 }
39 );
40 }
41
42 const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
43
44 const result = await openai.audio.transcriptions.create({
45 file: audio,
46 model: "whisper-1",
47 language: typeof language === "string" && language ? language : undefined,
48 response_format: "verbose_json",
49 });
50
51 const text = (result as unknown as { text: string }).text ?? "";
52 const detectedLanguage =
53 (result as unknown as { language?: string }).language ??
54 (typeof language === "string" ? language : undefined) ??
55 null;
56 const durationSeconds = (result as unknown as { duration?: number }).duration;
57 const durationMs =
58 typeof durationSeconds === "number" ? Math.round(durationSeconds * 1000) : null;
59
60 const saved = await prisma.voiceTranscript.create({
61 data: {
62 userId,
63 text,
64 durationMs: durationMs ?? undefined,
65 language: detectedLanguage ?? undefined,
66 surface: typeof surface === "string" && surface ? surface : undefined,
67 matterId: typeof matterId === "string" && matterId ? matterId : undefined,
68 },
69 });
70
71 return NextResponse.json({
72 id: saved.id,
73 text: saved.text,
74 durationMs: saved.durationMs,
75 language: saved.language,
76 });
77 } catch (error) {
78 console.error("Voice transcribe error:", error);
79 const message =
80 error instanceof Error ? error.message : "Failed to transcribe audio.";
81 return NextResponse.json({ error: message }, { status: 500 });
82 }
83}
Addedapp/api/voice/transcripts/route.ts+33−0View fileUnifiedSplit
@@ -0,0 +1,33 @@
1import { NextResponse } from "next/server";
2import { getServerSession } from "next-auth";
3import { authOptions } from "@/lib/auth";
4import { prisma } from "@/lib/prisma";
5
6export async function GET() {
7 try {
8 const session = await getServerSession(authOptions);
9
10 if (!session?.user) {
11 return NextResponse.json(
12 { error: "Authentication required." },
13 { status: 401 }
14 );
15 }
16
17 const userId = (session.user as unknown as { id: string }).id;
18
19 const transcripts = await prisma.voiceTranscript.findMany({
20 where: { userId },
21 orderBy: { createdAt: "desc" },
22 take: 50,
23 });
24
25 return NextResponse.json({ transcripts });
26 } catch (error) {
27 console.error("Voice transcripts list error:", error);
28 return NextResponse.json(
29 { error: "Failed to load transcripts." },
30 { status: 500 }
31 );
32 }
33}
Addedapp/api/webhooks/stripe/route.ts+141−0View fileUnifiedSplit
@@ -0,0 +1,141 @@
1import { NextResponse } from "next/server";
2import type Stripe from "stripe";
3import { headers } from "next/headers";
4import { stripe } from "@/lib/stripe";
5import { prisma } from "@/lib/prisma";
6
7export const runtime = "nodejs";
8
9export async function POST(req: Request) {
10 const body = await req.text();
11 const sig = headers().get("stripe-signature");
12 const secret = process.env.STRIPE_WEBHOOK_SECRET;
13
14 if (!sig || !secret) {
15 return NextResponse.json({ error: "Missing signature" }, { status: 400 });
16 }
17
18 let event: Stripe.Event;
19 try {
20 event = stripe.webhooks.constructEvent(body, sig, secret);
21 } catch (err) {
22 const msg = err instanceof Error ? err.message : "Invalid signature";
23 return NextResponse.json({ error: msg }, { status: 400 });
24 }
25
26 switch (event.type) {
27 case "checkout.session.completed": {
28 const s = event.data.object as Stripe.Checkout.Session;
29 if (s.mode === "subscription" && s.subscription) {
30 const userId =
31 s.client_reference_id || (s.metadata?.userId as string | undefined);
32 const sub = await stripe.subscriptions.retrieve(
33 s.subscription as string,
34 );
35 if (userId) {
36 await prisma.user.update({
37 where: { id: userId },
38 data: {
39 stripeCustomerId: (s.customer as string) || undefined,
40 stripeSubscriptionId: sub.id,
41 stripePriceId: sub.items.data[0]?.price.id,
42 subscriptionStatus: sub.status,
43 subscriptionPeriodEnd: new Date(
44 sub.current_period_end * 1000,
45 ),
46 },
47 });
48 }
49 }
50 break;
51 }
52
53 case "customer.subscription.updated": {
54 const sub = event.data.object as Stripe.Subscription;
55 await prisma.user.updateMany({
56 where: { stripeSubscriptionId: sub.id },
57 data: {
58 stripePriceId: sub.items.data[0]?.price.id,
59 subscriptionStatus: sub.status,
60 subscriptionPeriodEnd: new Date(sub.current_period_end * 1000),
61 },
62 });
63 break;
64 }
65
66 case "customer.subscription.deleted": {
67 const sub = event.data.object as Stripe.Subscription;
68 await prisma.user.updateMany({
69 where: { stripeSubscriptionId: sub.id },
70 data: { subscriptionStatus: "canceled" },
71 });
72 break;
73 }
74
75 case "account.updated": {
76 const account = event.data.object as Stripe.Account;
77 await prisma.user.updateMany({
78 where: { stripeConnectAccountId: account.id },
79 data: {
80 connectOnboarded:
81 account.details_submitted === true &&
82 account.charges_enabled === true,
83 },
84 });
85 break;
86 }
87
88 case "payment_intent.amount_capturable_updated": {
89 const pi = event.data.object as Stripe.PaymentIntent;
90 const professionalUserId = pi.metadata?.professionalUserId;
91 if (professionalUserId) {
92 await prisma.marketplacePayment.upsert({
93 where: { stripePaymentIntentId: pi.id },
94 create: {
95 stripePaymentIntentId: pi.id,
96 professionalUserId,
97 amountCents: pi.amount,
98 applicationFeeCents: pi.application_fee_amount || 0,
99 currency: pi.currency,
100 status: "requires_capture",
101 description: pi.description || undefined,
102 matterId: pi.metadata?.matterId || undefined,
103 },
104 update: { status: "requires_capture" },
105 });
106 }
107 break;
108 }
109
110 case "payment_intent.succeeded": {
111 const pi = event.data.object as Stripe.PaymentIntent;
112 await prisma.marketplacePayment.updateMany({
113 where: { stripePaymentIntentId: pi.id },
114 data: { status: "captured", capturedAt: new Date() },
115 });
116 break;
117 }
118
119 case "payment_intent.canceled": {
120 const pi = event.data.object as Stripe.PaymentIntent;
121 await prisma.marketplacePayment.updateMany({
122 where: { stripePaymentIntentId: pi.id },
123 data: { status: "canceled" },
124 });
125 break;
126 }
127
128 case "charge.refunded": {
129 const charge = event.data.object as Stripe.Charge;
130 if (charge.payment_intent) {
131 await prisma.marketplacePayment.updateMany({
132 where: { stripePaymentIntentId: charge.payment_intent as string },
133 data: { status: "refunded" },
134 });
135 }
136 break;
137 }
138 }
139
140 return NextResponse.json({ received: true });
141}
Modifiedapp/components/effects/TypingDemo.tsx+1−1View fileUnifiedSplit
@@ -5,7 +5,7 @@ import { useState, useEffect } from "react";
55const phrases = [
66 "Schedule a call with Patricia Thornton, Thursday at two pm.",
77 "Log four point five hours on the Rodriguez H-1B matter.",
8 "Ask the Oracle — California non-compete enforceability.",
8 "Ask Marco — California non-compete enforceability.",
99 "Draft a response to Marcus regarding the filing deadline.",
1010 "Send the engagement letter to Chen for e-signature.",
1111 "What are the tax implications of this corporate structure?",
Addedapp/components/marco/MarcoChat.tsx+299−0View fileUnifiedSplit
@@ -0,0 +1,299 @@
1"use client";
2
3import { useState, useRef, useEffect } from "react";
4import type { OracleResponse, OracleDomain } from "@/lib/oracle/types";
5
6type Message =
7 | { role: "user"; content: string; id: string }
8 | { role: "marco"; response: OracleResponse; id: string }
9 | { role: "error"; content: string; id: string };
10
11const DOMAIN_LABELS: Record<OracleDomain, string> = {
12 LEGAL: "Legal",
13 ACCOUNTING: "Accounting",
14 CROSS_DOMAIN: "Cross-domain",
15 IP: "Intellectual Property",
16};
17
18const STATUS_STYLES: Record<string, string> = {
19 VERIFIED: "bg-forest-50 text-forest-700 border-forest-200",
20 UNVERIFIED: "bg-navy-50 text-navy-500 border-navy-200",
21 NOT_FOUND: "bg-red-50 text-red-700 border-red-200",
22};
23
24export default function MarcoChat() {
25 const [messages, setMessages] = useState<Message[]>([]);
26 const [input, setInput] = useState("");
27 const [domain, setDomain] = useState<OracleDomain | "">("");
28 const [jurisdiction, setJurisdiction] = useState("");
29 const [loading, setLoading] = useState(false);
30 const scrollRef = useRef<HTMLDivElement>(null);
31
32 useEffect(() => {
33 scrollRef.current?.scrollTo({
34 top: scrollRef.current.scrollHeight,
35 behavior: "smooth",
36 });
37 }, [messages, loading]);
38
39 async function handleSubmit(e: React.FormEvent) {
40 e.preventDefault();
41 const query = input.trim();
42 if (!query || loading) return;
43
44 const userMsg: Message = {
45 role: "user",
46 content: query,
47 id: `u-${Date.now()}`,
48 };
49 setMessages((m) => [...m, userMsg]);
50 setInput("");
51 setLoading(true);
52
53 try {
54 const res = await fetch("/api/oracle/query", {
55 method: "POST",
56 headers: { "Content-Type": "application/json" },
57 body: JSON.stringify({
58 query,
59 domain: domain || undefined,
60 jurisdiction: jurisdiction || undefined,
61 surface: "marco-chat",
62 }),
63 });
64
65 if (!res.ok) {
66 const err = await res.json().catch(() => ({}));
67 throw new Error(err.error || `Request failed (${res.status})`);
68 }
69
70 const response: OracleResponse = await res.json();
71 setMessages((m) => [
72 ...m,
73 { role: "marco", response, id: `m-${Date.now()}` },
74 ]);
75 } catch (err) {
76 const msg = err instanceof Error ? err.message : "Something went wrong.";
77 setMessages((m) => [
78 ...m,
79 { role: "error", content: msg, id: `e-${Date.now()}` },
80 ]);
81 } finally {
82 setLoading(false);
83 }
84 }
85
86 return (
87 <div className="flex h-full flex-col rounded-2xl border border-navy-100 bg-white shadow-card">
88 {/* Header */}
89 <div className="flex items-center justify-between border-b border-navy-100 px-6 py-4">
90 <div>
91 <h2 className="font-serif text-xl text-navy-700">Marco</h2>
92 <p className="text-xs text-navy-400">
93 The greatest AI-generated mind for law and accountancy
94 </p>
95 </div>
96 <div className="flex flex-wrap items-center gap-2">
97 <select
98 value={domain}
99 onChange={(e) => setDomain(e.target.value as OracleDomain | "")}
100 className="rounded-lg border border-navy-200 bg-white px-3 py-1.5 text-xs text-navy-600 focus:border-navy-400 focus:outline-none"
101 aria-label="Domain"
102 >
103 <option value="">Auto-detect</option>
104 <option value="LEGAL">Legal</option>
105 <option value="ACCOUNTING">Accounting</option>
106 <option value="CROSS_DOMAIN">Cross-domain</option>
107 <option value="IP">IP</option>
108 </select>
109 <input
110 type="text"
111 placeholder="Jurisdiction"
112 value={jurisdiction}
113 onChange={(e) => setJurisdiction(e.target.value)}
114 className="w-32 rounded-lg border border-navy-200 bg-white px-3 py-1.5 text-xs text-navy-600 placeholder:text-navy-300 focus:border-navy-400 focus:outline-none"
115 />
116 </div>
117 </div>
118
119 {/* Messages */}
120 <div
121 ref={scrollRef}
122 className="flex-1 space-y-4 overflow-y-auto px-6 py-6"
123 style={{ minHeight: "400px", maxHeight: "60vh" }}
124 >
125 {messages.length === 0 && !loading && (
126 <div className="flex h-full flex-col items-center justify-center text-center">
127 <div className="font-serif text-2xl text-navy-700">
128 Ask Marco anything.
129 </div>
130 <p className="mt-2 max-w-md text-sm text-navy-400">
131 Legal research, tax questions, cross-domain analysis. Every
132 citation verified against authoritative public sources.
133 </p>
134 <div className="mt-6 flex flex-wrap justify-center gap-2">
135 {[
136 "California non-compete enforceability for tech employees",
137 "Section 199A QBI deduction threshold for 2025",
138 "Immigration tax implications of an H-1B holder forming an LLC",
139 ].map((s) => (
140 <button
141 key={s}
142 type="button"
143 onClick={() => setInput(s)}
144 className="rounded-full border border-navy-200 bg-navy-50 px-3 py-1.5 text-xs text-navy-600 transition-colors hover:bg-navy-100"
145 >
146 {s}
147 </button>
148 ))}
149 </div>
150 </div>
151 )}
152
153 {messages.map((msg) => {
154 if (msg.role === "user") {
155 return (
156 <div key={msg.id} className="flex justify-end">
157 <div className="max-w-[80%] rounded-2xl rounded-tr-md bg-navy-500 px-4 py-2.5 text-sm text-white">
158 {msg.content}
159 </div>
160 </div>
161 );
162 }
163
164 if (msg.role === "error") {
165 return (
166 <div key={msg.id} className="flex justify-start">
167 <div className="max-w-[80%] rounded-2xl rounded-tl-md border border-red-200 bg-red-50 px-4 py-2.5 text-sm text-red-700">
168 {msg.content}
169 </div>
170 </div>
171 );
172 }
173
174 const r = msg.response;
175 return (
176 <div key={msg.id} className="flex justify-start">
177 <div className="w-full max-w-[90%] space-y-3">
178 <div className="rounded-2xl rounded-tl-md border border-navy-100 bg-navy-50 px-5 py-4">
179 <div className="mb-2 flex items-center gap-2">
180 <span className="rounded-full bg-plum-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-plum-600">
181 {DOMAIN_LABELS[r.domain]}
182 </span>
183 <span className="text-[10px] text-navy-400">
184 {(r.responseTimeMs / 1000).toFixed(1)}s
185 </span>
186 </div>
187 <div className="whitespace-pre-wrap text-sm leading-relaxed text-navy-700">
188 {r.answer}
189 </div>
190 </div>
191
192 {r.citations.length > 0 && (
193 <div className="space-y-2">
194 <p className="text-[10px] font-semibold uppercase tracking-wider text-navy-400">
195 {r.citations.length} citation
196 {r.citations.length === 1 ? "" : "s"}
197 </p>
198 {r.citations.map((c, i) => (
199 <div
200 key={`${msg.id}-c-${i}`}
201 className="rounded-xl border border-navy-100 bg-white p-3"
202 >
203 <div className="flex items-start justify-between gap-3">
204 <div className="flex-1">
205 <p className="text-sm font-semibold text-navy-700">
206 {c.title}
207 </p>
208 <p className="mt-0.5 text-xs text-navy-400">
209 {c.citation}
210 {c.sourceDb && c.sourceDb !== "None"
211 ? ` · ${c.sourceDb}`
212 : ""}
213 </p>
214 </div>
215 <span
216 className={`shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-bold ${
217 STATUS_STYLES[c.status] || STATUS_STYLES.UNVERIFIED
218 }`}
219 >
220 {c.status}
221 </span>
222 </div>
223 {c.sourceUrl && (
224 <a
225 href={c.sourceUrl}
226 target="_blank"
227 rel="noopener noreferrer"
228 className="mt-2 inline-block text-xs font-semibold text-navy-500 hover:text-navy-700"
229 >
230 View source →
231 </a>
232 )}
233 </div>
234 ))}
235 </div>
236 )}
237
238 <p className="text-[10px] italic text-navy-300">
239 {r.disclaimer}
240 </p>
241 </div>
242 </div>
243 );
244 })}
245
246 {loading && (
247 <div className="flex justify-start">
248 <div className="rounded-2xl rounded-tl-md border border-navy-100 bg-navy-50 px-5 py-4">
249 <div className="flex items-center gap-2">
250 <div className="flex gap-1">
251 <div className="h-2 w-2 animate-bounce rounded-full bg-navy-400 [animation-delay:-0.3s]" />
252 <div className="h-2 w-2 animate-bounce rounded-full bg-navy-400 [animation-delay:-0.15s]" />
253 <div className="h-2 w-2 animate-bounce rounded-full bg-navy-400" />
254 </div>
255 <span className="text-xs text-navy-400">
256 Marco is researching…
257 </span>
258 </div>
259 </div>
260 </div>
261 )}
262 </div>
263
264 {/* Input */}
265 <form
266 onSubmit={handleSubmit}
267 className="border-t border-navy-100 px-6 py-4"
268 >
269 <div className="flex items-end gap-3">
270 <textarea
271 value={input}
272 onChange={(e) => setInput(e.target.value)}
273 onKeyDown={(e) => {
274 if (e.key === "Enter" && !e.shiftKey) {
275 e.preventDefault();
276 handleSubmit(e);
277 }
278 }}
279 placeholder="Ask Marco a legal or accounting question…"
280 rows={2}
281 disabled={loading}
282 className="min-h-[60px] flex-1 resize-none rounded-xl border border-navy-200 bg-white px-4 py-3 text-sm text-navy-700 placeholder:text-navy-300 focus:border-navy-400 focus:outline-none disabled:opacity-50"
283 maxLength={2000}
284 />
285 <button
286 type="submit"
287 disabled={loading || !input.trim()}
288 className="min-h-touch shrink-0 rounded-xl bg-navy-500 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-navy-600 disabled:cursor-not-allowed disabled:opacity-50"
289 >
290 {loading ? "Asking…" : "Ask"}
291 </button>
292 </div>
293 <p className="mt-2 text-[10px] text-navy-300">
294 Press Enter to send · Shift+Enter for new line · {input.length}/2000
295 </p>
296 </form>
297 </div>
298 );
299}
Addedapp/components/pricing/SubscribeButton.tsx+51−0View fileUnifiedSplit
@@ -0,0 +1,51 @@
1"use client";
2
3import { useSession } from "next-auth/react";
4import { useRouter } from "next/navigation";
5import { useState } from "react";
6
7export default function SubscribeButton({
8 priceId,
9 highlighted,
10 label = "Subscribe",
11}: {
12 priceId?: string;
13 highlighted?: boolean;
14 label?: string;
15}) {
16 const { status } = useSession();
17 const router = useRouter();
18 const [loading, setLoading] = useState(false);
19
20 async function handleClick() {
21 if (status !== "authenticated") {
22 router.push("/login?next=/pricing");
23 return;
24 }
25 if (!priceId) return;
26 setLoading(true);
27 try {
28 const res = await fetch("/api/billing/checkout", {
29 method: "POST",
30 headers: { "Content-Type": "application/json" },
31 body: JSON.stringify({ priceId }),
32 });
33 const data = (await res.json()) as { url?: string };
34 if (data.url) window.location.href = data.url;
35 } finally {
36 setLoading(false);
37 }
38 }
39
40 const base =
41 "mt-8 w-full rounded-lg px-4 py-3 text-sm font-semibold transition-colors disabled:opacity-50";
42 const cls = highlighted
43 ? `${base} bg-white text-navy-700 hover:bg-navy-50`
44 : `${base} bg-navy-700 text-white hover:bg-navy-800`;
45
46 return (
47 <button onClick={handleClick} disabled={loading || !priceId} className={cls}>
48 {loading ? "Loading..." : label}
49 </button>
50 );
51}
Addedapp/components/voice/VoiceRecorder.tsx+224−0View fileUnifiedSplit
@@ -0,0 +1,224 @@
1"use client";
2
3import { useEffect, useRef, useState } from "react";
4
5type RecorderState = "idle" | "recording" | "processing" | "error" | "success";
6
7interface VoiceRecorderProps {
8 onTranscript: (text: string) => void;
9 surface?: string;
10 matterId?: string;
11 placeholder?: string;
12}
13
14export default function VoiceRecorder({
15 onTranscript,
16 surface,
17 matterId,
18 placeholder,
19}: VoiceRecorderProps) {
20 const [state, setState] = useState<RecorderState>("idle");
21 const [error, setError] = useState<string | null>(null);
22 const mediaRecorderRef = useRef<MediaRecorder | null>(null);
23 const chunksRef = useRef<Blob[]>([]);
24 const streamRef = useRef<MediaStream | null>(null);
25 const startedAtRef = useRef<number>(0);
26 const cancelledRef = useRef<boolean>(false);
27
28 useEffect(() => {
29 return () => {
30 stopStream();
31 };
32 }, []);
33
34 function stopStream() {
35 if (streamRef.current) {
36 streamRef.current.getTracks().forEach((t) => t.stop());
37 streamRef.current = null;
38 }
39 }
40
41 async function handleStart() {
42 setError(null);
43 cancelledRef.current = false;
44 if (typeof window === "undefined" || !navigator.mediaDevices?.getUserMedia) {
45 setState("error");
46 setError("Microphone access is not supported in this browser.");
47 return;
48 }
49 try {
50 const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
51 streamRef.current = stream;
52 const mimeType = pickMimeType();
53 const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
54 chunksRef.current = [];
55 recorder.ondataavailable = (e) => {
56 if (e.data && e.data.size > 0) chunksRef.current.push(e.data);
57 };
58 recorder.onstop = async () => {
59 stopStream();
60 if (cancelledRef.current) {
61 setState("idle");
62 return;
63 }
64 const blob = new Blob(chunksRef.current, {
65 type: recorder.mimeType || "audio/webm",
66 });
67 await uploadBlob(blob);
68 };
69 recorder.start();
70 mediaRecorderRef.current = recorder;
71 startedAtRef.current = Date.now();
72 setState("recording");
73 } catch (err) {
74 console.error(err);
75 setState("error");
76 setError(
77 err instanceof Error && err.name === "NotAllowedError"
78 ? "Microphone permission denied. Please allow access and try again."
79 : "Could not access microphone."
80 );
81 }
82 }
83
84 function handleStop() {
85 const recorder = mediaRecorderRef.current;
86 if (recorder && recorder.state !== "inactive") {
87 setState("processing");
88 recorder.stop();
89 }
90 }
91
92 function handleCancel() {
93 cancelledRef.current = true;
94 const recorder = mediaRecorderRef.current;
95 if (recorder && recorder.state !== "inactive") {
96 recorder.stop();
97 }
98 stopStream();
99 setState("idle");
100 }
101
102 async function uploadBlob(blob: Blob) {
103 try {
104 setState("processing");
105 const form = new FormData();
106 const ext = blob.type.includes("mp4")
107 ? "mp4"
108 : blob.type.includes("ogg")
109 ? "ogg"
110 : "webm";
111 form.append("audio", blob, `recording.${ext}`);
112 if (surface) form.append("surface", surface);
113 if (matterId) form.append("matterId", matterId);
114 const res = await fetch("/api/voice/transcribe", {
115 method: "POST",
116 body: form,
117 });
118 if (!res.ok) {
119 const data = await res.json().catch(() => ({}));
120 throw new Error(data.error || "Transcription failed.");
121 }
122 const data = (await res.json()) as { text: string };
123 onTranscript(data.text);
124 setState("success");
125 setTimeout(() => {
126 setState((s) => (s === "success" ? "idle" : s));
127 }, 2000);
128 } catch (err) {
129 console.error(err);
130 setState("error");
131 setError(err instanceof Error ? err.message : "Transcription failed.");
132 }
133 }
134
135 const isRecording = state === "recording";
136 const isProcessing = state === "processing";
137
138 return (
139 <div className="rounded-2xl border border-forest-200 bg-forest-50 p-5 sm:p-6">
140 <div className="flex items-center gap-4">
141 <div className="flex h-8 items-end gap-[3px]">
142 {Array.from({ length: 24 }).map((_, i) => (
143 <div
144 key={i}
145 className="waveform-bar w-[3px] rounded-full bg-forest-500"
146 style={{
147 height: isRecording ? "100%" : "25%",
148 animationPlayState: isRecording ? "running" : "paused",
149 animationDelay: `${i * 0.05}s`,
150 opacity: isRecording ? 1 : 0.5,
151 }}
152 />
153 ))}
154 </div>
155 <div className="flex-1">
156 <p className="text-sm font-semibold text-forest-700">
157 Marco Reid Voice
158 {isRecording && " — Recording"}
159 {isProcessing && " — Transcribing…"}
160 {state === "success" && " — Transcribed"}
161 </p>
162 <p className="mt-0.5 text-xs text-forest-500">
163 {placeholder || "Press Start and speak naturally. Whisper will transcribe."}
164 </p>
165 </div>
166 </div>
167
168 <div className="mt-5 flex flex-wrap gap-3">
169 {!isRecording && !isProcessing && (
170 <button
171 type="button"
172 onClick={handleStart}
173 className="inline-flex min-h-touch items-center justify-center rounded-full bg-forest-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-forest-600"
174 >
175 Start recording
176 </button>
177 )}
178 {isRecording && (
179 <>
180 <button
181 type="button"
182 onClick={handleStop}
183 className="inline-flex min-h-touch items-center justify-center rounded-full bg-navy-700 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-navy-800"
184 >
185 Stop & transcribe
186 </button>
187 <button
188 type="button"
189 onClick={handleCancel}
190 className="inline-flex min-h-touch items-center justify-center rounded-full border border-navy-200 bg-white px-6 py-2 text-sm font-semibold text-navy-700 transition-colors hover:bg-navy-50"
191 >
192 Cancel
193 </button>
194 </>
195 )}
196 {isProcessing && (
197 <span className="inline-flex min-h-touch items-center text-sm text-navy-500">
198 Sending to Whisper…
199 </span>
200 )}
201 </div>
202
203 {error && (
204 <p className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700">
205 {error}
206 </p>
207 )}
208 </div>
209 );
210}
211
212function pickMimeType(): string | undefined {
213 if (typeof MediaRecorder === "undefined") return undefined;
214 const candidates = [
215 "audio/webm;codecs=opus",
216 "audio/webm",
217 "audio/mp4",
218 "audio/ogg;codecs=opus",
219 ];
220 for (const c of candidates) {
221 if (MediaRecorder.isTypeSupported?.(c)) return c;
222 }
223 return undefined;
224}
Modifiedlib/constants.ts+1−1View fileUnifiedSplit
@@ -58,7 +58,7 @@ export const PRODUCTS: Record<string, Product> = {
5858 slug: "/marco",
5959 tagline: "Your AI partner for law and accounting",
6060 description:
61 "Cross-domain legal and accounting AI research. Ask questions that span both disciplines simultaneously. Every citation verified against authoritative public domain sources.",
61 "Cross-domain legal and accounting AI research. Ask Marco questions that span both disciplines simultaneously. Every citation verified against authoritative public domain sources.",
6262 features: [
6363 "Legal research across all public domain case law and statutes",
6464 "Accounting research across tax codes and regulations",
Modifiedlib/oracle/verify.ts+1−1View fileUnifiedSplit
@@ -3,7 +3,7 @@ import { VerificationStatus, OracleCitationResult } from "./types";
33/**
44 * Citation verification engine.
55 *
6 * Every citation The Oracle returns MUST pass through this layer before
6 * Every citation Marco returns MUST pass through this layer before
77 * being displayed to the user. Per Claude.MD Section 21:
88 * - Citation existence check against authoritative public sources
99 * - Dead link prevention — every citation must include a source link
Addedlib/session.ts+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1import { getServerSession } from "next-auth";
2import { authOptions } from "@/lib/auth";
3
4export async function getUserId(): Promise<string | null> {
5 const session = await getServerSession(authOptions);
6 const id = (session?.user as { id?: string } | undefined)?.id;
7 return id ?? null;
8}
Addedlib/stripe.ts+134−0View fileUnifiedSplit
@@ -0,0 +1,134 @@
1import Stripe from "stripe";
2import { prisma } from "@/lib/prisma";
3
4export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
5 apiVersion: "2024-11-20.acacia",
6 typescript: true,
7});
8
9export async function getOrCreateCustomer(
10 userId: string,
11 email: string,
12 name?: string | null,
13): Promise<string> {
14 const user = await prisma.user.findUnique({ where: { id: userId } });
15 if (user?.stripeCustomerId) return user.stripeCustomerId;
16
17 const customer = await stripe.customers.create({
18 email,
19 name: name || undefined,
20 metadata: { userId },
21 });
22
23 await prisma.user.update({
24 where: { id: userId },
25 data: { stripeCustomerId: customer.id },
26 });
27
28 return customer.id;
29}
30
31export async function createCheckoutSession(params: {
32 userId: string;
33 priceId: string;
34 successUrl: string;
35 cancelUrl: string;
36}): Promise<Stripe.Checkout.Session> {
37 const user = await prisma.user.findUnique({ where: { id: params.userId } });
38 if (!user) throw new Error("User not found");
39
40 const customerId = await getOrCreateCustomer(user.id, user.email, user.name);
41
42 return stripe.checkout.sessions.create({
43 mode: "subscription",
44 customer: customerId,
45 line_items: [{ price: params.priceId, quantity: 1 }],
46 success_url: params.successUrl,
47 cancel_url: params.cancelUrl,
48 client_reference_id: user.id,
49 metadata: { userId: user.id },
50 });
51}
52
53export async function createBillingPortalSession(params: {
54 customerId: string;
55 returnUrl: string;
56}): Promise<Stripe.BillingPortal.Session> {
57 return stripe.billingPortal.sessions.create({
58 customer: params.customerId,
59 return_url: params.returnUrl,
60 });
61}
62
63export async function createConnectAccountLink(params: {
64 userId: string;
65 returnUrl: string;
66 refreshUrl: string;
67}): Promise<{ url: string; accountId: string }> {
68 const user = await prisma.user.findUnique({ where: { id: params.userId } });
69 if (!user) throw new Error("User not found");
70
71 let accountId = user.stripeConnectAccountId;
72 if (!accountId) {
73 const account = await stripe.accounts.create({
74 type: "express",
75 email: user.email,
76 metadata: { userId: user.id },
77 capabilities: {
78 transfers: { requested: true },
79 card_payments: { requested: true },
80 },
81 });
82 accountId = account.id;
83 await prisma.user.update({
84 where: { id: user.id },
85 data: { stripeConnectAccountId: accountId },
86 });
87 }
88
89 const link = await stripe.accountLinks.create({
90 account: accountId,
91 return_url: params.returnUrl,
92 refresh_url: params.refreshUrl,
93 type: "account_onboarding",
94 });
95
96 return { url: link.url, accountId };
97}
98
99export async function createMarketplaceCheckoutSession(params: {
100 amountCents: number;
101 professionalConnectAccountId: string;
102 customerEmail?: string;
103 description: string;
104 applicationFeeCents: number;
105 successUrl?: string;
106 cancelUrl?: string;
107 metadata?: Record<string, string>;
108}): Promise<Stripe.Checkout.Session> {
109 const base = process.env.NEXTAUTH_URL || "http://localhost:3000";
110 return stripe.checkout.sessions.create({
111 mode: "payment",
112 customer_email: params.customerEmail,
113 line_items: [
114 {
115 price_data: {
116 currency: "usd",
117 product_data: { name: params.description },
118 unit_amount: params.amountCents,
119 },
120 quantity: 1,
121 },
122 ],
123 payment_intent_data: {
124 capture_method: "manual",
125 application_fee_amount: params.applicationFeeCents,
126 transfer_data: { destination: params.professionalConnectAccountId },
127 description: params.description,
128 metadata: params.metadata,
129 },
130 success_url: params.successUrl || `${base}/marketplace/success`,
131 cancel_url: params.cancelUrl || `${base}/marketplace/cancel`,
132 metadata: params.metadata,
133 });
134}
Modifiedpackage-lock.json+143−24View fileUnifiedSplit
@@ -1,11 +1,11 @@
11{
2 "name": "alecrae",
2 "name": "marcoreid",
33 "version": "0.1.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
8 "name": "alecrae",
8 "name": "marcoreid",
99 "version": "0.1.0",
1010 "dependencies": {
1111 "@anthropic-ai/sdk": "^0.82.0",
@@ -2317,6 +2317,18 @@
23172317 "win32"
23182318 ]
23192319 },
2320 "node_modules/abort-controller": {
2321 "version": "3.0.0",
2322 "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
2323 "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
2324 "license": "MIT",
2325 "dependencies": {
2326 "event-target-shim": "^5.0.0"
2327 },
2328 "engines": {
2329 "node": ">=6.5"
2330 }
2331 },
23202332 "node_modules/acorn": {
23212333 "version": "8.16.0",
23222334 "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2580,6 +2592,12 @@
25802592 "node": ">= 0.4"
25812593 }
25822594 },
2595 "node_modules/asynckit": {
2596 "version": "0.4.0",
2597 "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
2598 "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
2599 "license": "MIT"
2600 },
25832601 "node_modules/available-typed-arrays": {
25842602 "version": "1.0.7",
25852603 "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2725,7 +2743,6 @@
27252743 "version": "1.0.2",
27262744 "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
27272745 "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
2728 "dev": true,
27292746 "license": "MIT",
27302747 "dependencies": {
27312748 "es-errors": "^1.3.0",
@@ -2739,7 +2756,6 @@
27392756 "version": "1.0.4",
27402757 "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
27412758 "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
2742 "dev": true,
27432759 "license": "MIT",
27442760 "dependencies": {
27452761 "call-bind-apply-helpers": "^1.0.2",
@@ -3012,7 +3028,6 @@
30123028 "version": "1.0.1",
30133029 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
30143030 "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
3015 "dev": true,
30163031 "license": "MIT",
30173032 "dependencies": {
30183033 "call-bind-apply-helpers": "^1.0.1",
@@ -3130,7 +3145,6 @@
31303145 "version": "1.0.1",
31313146 "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
31323147 "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
3133 "dev": true,
31343148 "license": "MIT",
31353149 "engines": {
31363150 "node": ">= 0.4"
@@ -3140,7 +3154,6 @@
31403154 "version": "1.3.0",
31413155 "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
31423156 "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
3143 "dev": true,
31443157 "license": "MIT",
31453158 "engines": {
31463159 "node": ">= 0.4"
@@ -3179,7 +3192,6 @@
31793192 "version": "1.1.1",
31803193 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
31813194 "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
3182 "dev": true,
31833195 "license": "MIT",
31843196 "dependencies": {
31853197 "es-errors": "^1.3.0"
@@ -3192,7 +3204,6 @@
31923204 "version": "2.1.0",
31933205 "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
31943206 "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
3195 "dev": true,
31963207 "license": "MIT",
31973208 "dependencies": {
31983209 "es-errors": "^1.3.0",
@@ -3760,6 +3771,15 @@
37603771 "node": ">=0.10.0"
37613772 }
37623773 },
3774 "node_modules/event-target-shim": {
3775 "version": "5.0.1",
3776 "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
3777 "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
3778 "license": "MIT",
3779 "engines": {
3780 "node": ">=6"
3781 }
3782 },
37633783 "node_modules/fast-deep-equal": {
37643784 "version": "3.1.3",
37653785 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3875,7 +3895,6 @@
38753895 "version": "2.3.3",
38763896 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
38773897 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
3878 "dev": true,
38793898 "hasInstallScript": true,
38803899 "license": "MIT",
38813900 "optional": true,
@@ -3890,7 +3909,6 @@
38903909 "version": "1.1.2",
38913910 "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
38923911 "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
3893 "dev": true,
38943912 "license": "MIT",
38953913 "funding": {
38963914 "url": "https://github.com/sponsors/ljharb"
@@ -3951,7 +3969,6 @@
39513969 "version": "1.3.0",
39523970 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
39533971 "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
3954 "dev": true,
39553972 "license": "MIT",
39563973 "dependencies": {
39573974 "call-bind-apply-helpers": "^1.0.2",
@@ -3976,7 +3993,6 @@
39763993 "version": "1.0.1",
39773994 "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
39783995 "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
3979 "dev": true,
39803996 "license": "MIT",
39813997 "dependencies": {
39823998 "dunder-proto": "^1.0.1",
@@ -4064,7 +4080,6 @@
40644080 "version": "1.2.0",
40654081 "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
40664082 "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
4067 "dev": true,
40684083 "license": "MIT",
40694084 "engines": {
40704085 "node": ">= 0.4"
@@ -4151,7 +4166,6 @@
41514166 "version": "1.1.0",
41524167 "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
41534168 "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
4154 "dev": true,
41554169 "license": "MIT",
41564170 "engines": {
41574171 "node": ">= 0.4"
@@ -4164,7 +4178,6 @@
41644178 "version": "1.0.2",
41654179 "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
41664180 "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
4167 "dev": true,
41684181 "license": "MIT",
41694182 "dependencies": {
41704183 "has-symbols": "^1.0.3"
@@ -4180,7 +4193,6 @@
41804193 "version": "2.0.2",
41814194 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
41824195 "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
4183 "dev": true,
41844196 "license": "MIT",
41854197 "dependencies": {
41864198 "function-bind": "^1.1.2"
@@ -5182,7 +5194,6 @@
51825194 "version": "1.1.0",
51835195 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
51845196 "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
5185 "dev": true,
51865197 "license": "MIT",
51875198 "engines": {
51885199 "node": ">= 0.4"
@@ -5212,6 +5223,27 @@
52125223 "node": ">=8.6"
52135224 }
52145225 },
5226 "node_modules/mime-db": {
5227 "version": "1.52.0",
5228 "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
5229 "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
5230 "license": "MIT",
5231 "engines": {
5232 "node": ">= 0.6"
5233 }
5234 },
5235 "node_modules/mime-types": {
5236 "version": "2.1.35",
5237 "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
5238 "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
5239 "license": "MIT",
5240 "dependencies": {
5241 "mime-db": "1.52.0"
5242 },
5243 "engines": {
5244 "node": ">= 0.6"
5245 }
5246 },
52155247 "node_modules/minimatch": {
52165248 "version": "3.1.5",
52175249 "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -5249,7 +5281,6 @@
52495281 "version": "2.1.3",
52505282 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
52515283 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
5252 "dev": true,
52535284 "license": "MIT"
52545285 },
52555286 "node_modules/nanoid": {
@@ -5406,6 +5437,26 @@
54065437 "node": "^10 || ^12 || >=14"
54075438 }
54085439 },
5440 "node_modules/node-domexception": {
5441 "version": "1.0.0",
5442 "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
5443 "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
5444 "deprecated": "Use your platform's native DOMException instead",
5445 "funding": [
5446 {
5447 "type": "github",
5448 "url": "https://github.com/sponsors/jimmywarting"
5449 },
5450 {
5451 "type": "github",
5452 "url": "https://paypal.me/jimmywarting"
5453 }
5454 ],
5455 "license": "MIT",
5456 "engines": {
5457 "node": ">=10.5.0"
5458 }
5459 },
54095460 "node_modules/node-exports-info": {
54105461 "version": "1.6.0",
54115462 "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -5462,7 +5513,6 @@
54625513 "version": "1.13.4",
54635514 "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
54645515 "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
5465 "dev": true,
54665516 "license": "MIT",
54675517 "engines": {
54685518 "node": ">= 0.4"
@@ -5590,6 +5640,51 @@
55905640 "opener": "bin/opener-bin.js"
55915641 }
55925642 },
5643 "node_modules/openai": {
5644 "version": "4.104.0",
5645 "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
5646 "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
5647 "license": "Apache-2.0",
5648 "dependencies": {
5649 "@types/node": "^18.11.18",
5650 "@types/node-fetch": "^2.6.4",
5651 "abort-controller": "^3.0.0",
5652 "agentkeepalive": "^4.2.1",
5653 "form-data-encoder": "1.7.2",
5654 "formdata-node": "^4.3.2",
5655 "node-fetch": "^2.6.7"
5656 },
5657 "bin": {
5658 "openai": "bin/cli"
5659 },
5660 "peerDependencies": {
5661 "ws": "^8.18.0",
5662 "zod": "^3.23.8"
5663 },
5664 "peerDependenciesMeta": {
5665 "ws": {
5666 "optional": true
5667 },
5668 "zod": {
5669 "optional": true
5670 }
5671 }
5672 },
5673 "node_modules/openai/node_modules/@types/node": {
5674 "version": "18.19.130",
5675 "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
5676 "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
5677 "license": "MIT",
5678 "dependencies": {
5679 "undici-types": "~5.26.4"
5680 }
5681 },
5682 "node_modules/openai/node_modules/undici-types": {
5683 "version": "5.26.5",
5684 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
5685 "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
5686 "license": "MIT"
5687 },
55935688 "node_modules/openid-client": {
55945689 "version": "5.7.1",
55955690 "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
@@ -5918,6 +6013,21 @@
59186013 "node": ">=6"
59196014 }
59206015 },
6016 "node_modules/qs": {
6017 "version": "6.15.0",
6018 "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
6019 "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
6020 "license": "BSD-3-Clause",
6021 "dependencies": {
6022 "side-channel": "^1.1.0"
6023 },
6024 "engines": {
6025 "node": ">=0.6"
6026 },
6027 "funding": {
6028 "url": "https://github.com/sponsors/ljharb"
6029 }
6030 },
59216031 "node_modules/queue-microtask": {
59226032 "version": "1.2.3",
59236033 "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6282,7 +6392,6 @@
62826392 "version": "1.1.0",
62836393 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
62846394 "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
6285 "dev": true,
62866395 "license": "MIT",
62876396 "dependencies": {
62886397 "es-errors": "^1.3.0",
@@ -6302,7 +6411,6 @@
63026411 "version": "1.0.0",
63036412 "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
63046413 "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
6305 "dev": true,
63066414 "license": "MIT",
63076415 "dependencies": {
63086416 "es-errors": "^1.3.0",
@@ -6319,7 +6427,6 @@
63196427 "version": "1.0.1",
63206428 "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
63216429 "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
6322 "dev": true,
63236430 "license": "MIT",
63246431 "dependencies": {
63256432 "call-bound": "^1.0.2",
@@ -6338,7 +6445,6 @@
63386445 "version": "1.0.2",
63396446 "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
63406447 "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
6341 "dev": true,
63426448 "license": "MIT",
63436449 "dependencies": {
63446450 "call-bound": "^1.0.2",
@@ -6535,6 +6641,19 @@
65356641 "url": "https://github.com/sponsors/sindresorhus"
65366642 }
65376643 },
6644 "node_modules/stripe": {
6645 "version": "17.7.0",
6646 "resolved": "https://registry.npmjs.org/stripe/-/stripe-17.7.0.tgz",
6647 "integrity": "sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==",
6648 "license": "MIT",
6649 "dependencies": {
6650 "@types/node": ">=8.1.0",
6651 "qs": "^6.11.0"
6652 },
6653 "engines": {
6654 "node": ">=12.*"
6655 }
6656 },
65386657 "node_modules/styled-jsx": {
65396658 "version": "5.1.6",
65406659 "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
Modifiedprisma/schema.prisma+285−63View fileUnifiedSplit
@@ -18,16 +18,216 @@ enum Role {
1818}
1919
2020model User {
21 id String
22 email String
21 id String
22 email String
2323 name String?
2424 passwordHash String
25 role Role
25 role Role
2626 firmName String?
27 createdAt DateTime
28 updatedAt DateTime
29 queries OracleQuery[]
30 feedback OracleFeedback[]
27 createdAt DateTime
28 updatedAt DateTime
29 queries OracleQuery[]
30 feedback OracleFeedback[]
31 clients Client[]
32 matters Matter[]
33 documents Document[]
34 timeEntries TimeEntry[]
35 trustAccounts TrustAccount[]
36 voiceTranscripts VoiceTranscript[]
37
38 // Stripe — subscriptions
39 stripeCustomerId String?
40 stripeSubscriptionId String?
41 stripePriceId String?
42 subscriptionStatus String?
43 subscriptionPeriodEnd DateTime?
44
45 // Stripe Connect — marketplace
46 stripeConnectAccountId String?
47 connectOnboarded Boolean
48 marketplacePayments MarketplacePayment[]
49}
50
51// ============================================================
52// PRACTICE FOUNDATION — Clients, Matters, Documents, Billing
53// ============================================================
54
55enum MatterStatus {
56 ACTIVE
57 ON_HOLD
58 CLOSED
59}
60
61enum DocumentKind {
62 CONTRACT
63 LETTER
64 COURT_FILING
65 EVIDENCE
66 INVOICE
67 RECEIPT
68 OTHER
69}
70
71enum TrustTransactionType {
72 DEPOSIT
73 WITHDRAWAL
74 FEE_DRAW
75}
76
77model Client {
78 id String
79 userId String
80 user User
81 name String
82 email String
83 phone String?
84 address String?
85 companyName String?
86 notes String?
87 matters Matter[]
88 documents Document[]
89 trustAccounts TrustAccount[]
90 createdAt DateTime
91 updatedAt DateTime
92
93 @
94}
95
96model Matter {
97 id String
98 userId String
99 user User
100 clientId String
101 client Client
102 title String
103 matterNumber String?
104 practiceArea String?
105 status MatterStatus
106 description String?
107 openedAt DateTime
108 closedAt DateTime?
109 documents Document[]
110 timeEntries TimeEntry[]
111 trustTransactions TrustTransaction[]
112 createdAt DateTime
113 updatedAt DateTime
114
115 @
116 @
117 @
118 @
119}
120
121model Document {
122 id String
123 userId String
124 user User
125 matterId String?
126 matter Matter?
127 clientId String?
128 client Client?
129 title String
130 fileName String
131 fileUrl String
132 fileSize Int
133 mimeType String
134 kind DocumentKind
135 createdAt DateTime
136 updatedAt DateTime
137
138 @
139 @
140}
141
142model TimeEntry {
143 id String
144 userId String
145 user User
146 matterId String
147 matter Matter
148 description String
149 minutes Int
150 rateInCents Int
151 date DateTime
152 billable Boolean
153 invoiced Boolean
154 createdAt DateTime
155
156 @
157 @
158 @
159}
160
161model TrustAccount {
162 id String
163 userId String
164 user User
165 clientId String
166 client Client
167 balanceInCents Int
168 currency String
169 transactions TrustTransaction[]
170 createdAt DateTime
171 updatedAt DateTime
172
173 @
174}
175
176model TrustTransaction {
177 id String
178 trustAccountId String
179 trustAccount TrustAccount
180 type TrustTransactionType
181 amountInCents Int
182 description String
183 matterId String?
184 matter Matter?
185 createdAt DateTime
186
187 @
188}
189
190// ============================================================
191// MARKETPLACE — Stripe Connect escrow payments
192// ============================================================
193
194model MarketplacePayment {
195 id String
196 payerUserId String?
197 professionalUserId String
198 professional User
199 stripePaymentIntentId String
200 amountCents Int
201 applicationFeeCents Int
202 currency String
203 status String
204 description String?
205 matterId String?
206 capturedAt DateTime?
207 createdAt DateTime
208 updatedAt DateTime
209
210 @
211 @
212}
213
214// ============================================================
215// MARCO REID VOICE — Whisper dictation
216// ============================================================
217
218model VoiceTranscript {
219 id String
220 userId String
221 user User
222 text String .Text
223 durationMs Int?
224 language String? // detected or specified language
225 surface String? // where the dictation happened: "dashboard", "matter", "email", etc.
226 matterId String? // optional link to matter
227 createdAt DateTime
228
229 @
230 @
31231}
32232
33233// ============================================================
@@ -49,20 +249,20 @@ enum CitationStatus {
49249
50250// Every query ever made — the memory
51251model OracleQuery {
52 id String
53 userId String
54 user User
55 query String // The raw question asked
56 domain QueryDomain // Legal, Accounting, Cross-domain, IP
57 jurisdiction String? // US, NZ, AU, UK, or specific state
58 context String? // Matter context, previous queries, etc.
59 response String // The Oracle's full response
60 citations OracleCitation[]
61 feedback OracleFeedback[]
62 matterId String? // Link to matter if query was in-context
63 surface String? // Where the query was made: document, email, cmd-k, etc.
64 responseTimeMs Int? // How long the response took
65 createdAt DateTime
252 id String
253 userId String
254 user User
255 query String // The raw question asked
256 domain QueryDomain // Legal, Accounting, Cross-domain, IP
257 jurisdiction String? // US, NZ, AU, UK, or specific state
258 context String? // Matter context, previous queries, etc.
259 response String // Marco's full response
260 citations OracleCitation[]
261 feedback OracleFeedback[]
262 matterId String? // Link to matter if query was in-context
263 surface String? // Where the query was made: document, email, cmd-k, etc.
264 responseTimeMs Int? // How long the response took
265 createdAt DateTime
66266
67267 @
68268 @
@@ -72,20 +272,20 @@ model OracleQuery {
72272
73273// Every citation returned — verified or not
74274model OracleCitation {
75 id String
76 queryId String
77 query OracleQuery
78 title String // Case name, statute title, IRS section
79 citation String // Formal citation string
80 sourceUrl String? // Direct link to authoritative source
81 sourceDb String? // CourtListener, IRS.gov, Cornell LII, USPTO, etc.
82 status CitationStatus // VERIFIED, UNVERIFIED, NOT_FOUND
83 excerpt String? // Relevant excerpt from the source
84 jurisdiction String? // Jurisdiction of the citation
85 dateDecided String? // Date of case decision or ruling
86 wasInserted Boolean // Did the user insert this into a document?
87 wasClicked Boolean // Did the user click to view full text?
88 createdAt DateTime
275 id String
276 queryId String
277 query OracleQuery
278 title String // Case name, statute title, IRS section
279 citation String // Formal citation string
280 sourceUrl String? // Direct link to authoritative source
281 sourceDb String? // CourtListener, IRS.gov, Cornell LII, USPTO, etc.
282 status CitationStatus // VERIFIED, UNVERIFIED, NOT_FOUND
283 excerpt String? // Relevant excerpt from the source
284 jurisdiction String? // Jurisdiction of the citation
285 dateDecided String? // Date of case decision or ruling
286 wasInserted Boolean // Did the user insert this into a document?
287 wasClicked Boolean // Did the user click to view full text?
288 createdAt DateTime
89289
90290 @
91291 @
@@ -94,36 +294,36 @@ model OracleCitation {
94294
95295// User feedback on results — the flywheel learning signal
96296model OracleFeedback {
97 id String
98 queryId String
99 query OracleQuery
100 userId String
101 user User
102 rating Int // 1-5 star rating
103 helpful Boolean? // Was this result helpful?
104 accurate Boolean? // Was this result accurate?
105 comment String? // Free text feedback
106 createdAt DateTime
297 id String
298 queryId String
299 query OracleQuery
300 userId String
301 user User
302 rating Int // 1-5 star rating
303 helpful Boolean? // Was this result helpful?
304 accurate Boolean? // Was this result accurate?
305 comment String? // Free text feedback
306 createdAt DateTime
107307
108308 @
109309 @
110310}
111311
112312// ============================================================
113// DATA SOURCES — what The Oracle knows about
313// DATA SOURCES — what Marco knows about
114314// ============================================================
115315
116316model DataSource {
117 id String
118 name String // CourtListener, IRS.gov, Cornell LII, etc.
119 domain QueryDomain // What domain this source covers
120 baseUrl String // API endpoint or base URL
121 apiKey String? // API key if required (encrypted in env)
122 lastSynced DateTime? // Last time we pulled data
123 recordCount Int // How many records we have
124 isActive Boolean
125 createdAt DateTime
126 updatedAt DateTime
317 id String
318 name String // CourtListener, IRS.gov, Cornell LII, etc.
319 domain QueryDomain // What domain this source covers
320 baseUrl String // API endpoint or base URL
321 apiKey String? // API key if required (encrypted in env)
322 lastSynced DateTime? // Last time we pulled data
323 recordCount Int // How many records we have
324 isActive Boolean
325 createdAt DateTime
326 updatedAt DateTime
127327}
128328
129329// ============================================================
@@ -132,14 +332,36 @@ model DataSource {
132332
133333// Tracks what queries are most common — informs data priority
134334model QueryPattern {
135 id String
136 pattern String // Normalised query pattern
137 domain QueryDomain
138 count Int // How many times this pattern was queried
139 avgRating Float? // Average user rating for this pattern
140 lastQueried DateTime
141 createdAt DateTime
335 id String
336 pattern String // Normalised query pattern
337 domain QueryDomain
338 count Int // How many times this pattern was queried
339 avgRating Float? // Average user rating for this pattern
340 lastQueried DateTime
341 createdAt DateTime
142342
143343 @
144344 @
145345}
346
347// ============================================================
348// COURTS — pilot requests from court procurement leads
349// ============================================================
350
351model CourtPilotRequest {
352 id String
353 name String
354 role String
355 court String
356 jurisdiction String
357 email String
358 phone String?
359 products String[]
360 useCase String .Text
361 status String
362 createdAt DateTime
363 updatedAt DateTime
364
365 @
366 @
367}
146368
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts