Add in-app Contact Support dialog and Help Center docs

Route all support channels to support@stillwell.cloud for Libredesk
email ingest, add a global Contact Support dialog (category/subject/
message) that posts to POST /api/support with per-user rate limiting,
and build a /docs Help Center hub with Getting Started, Uploading
Cards, Forms & Templates, Reports & Exports, FAQ, and Troubleshooting
sections. Replaces stale echoocr.com/echoocr.app addresses across
marketing footer, terms, privacy, pricing, welcome, and features.

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-24 15:10:56 -05:00
parent be7e3dc502
commit 3a75bfa7de
23 changed files with 1699 additions and 26 deletions

View file

@ -34,6 +34,11 @@ BREVO_API_KEY=""
EMAIL_FROM_NAME="Echo OCR"
EMAIL_FROM_ADDRESS="mars@noreply.stillwell.cloud"
# ─── Support ticketing (Libredesk) ────────────────────────────
# Inbox address that Libredesk ingests as tickets.
# The in-app Contact Support dialog sends tickets here via Brevo.
SUPPORT_EMAIL="support@stillwell.cloud"
# ─── Vercel Cron Secret ────────────────────────────────────────
# Vercel auto-sets this on Pro. Used to authenticate cron job requests.
CRON_SECRET=""

View file

@ -0,0 +1,96 @@
import { HelpCircle } from "lucide-react";
import {
DocArticle,
DocSection,
DocContactCta,
} from "@/components/support/doc-article";
export const metadata = {
title: "FAQ — Echo Help",
};
export default function FaqPage() {
return (
<DocArticle
title="Frequently Asked Questions"
description="Quick answers to what comes up most often."
icon={HelpCircle}
>
<DocSection title="How accurate is the OCR?">
<p>
For clean, typed-text cards Echo averages better than 98% accuracy.
Handwriting varies widely &mdash; expect 85&ndash;95% on neat printing
and lower on cursive. Every card passes through a{" "}
<strong>Needs Review</strong> state so a human can correct anything
the AI wasn&rsquo;t sure about before it hits your CRM.
</p>
</DocSection>
<DocSection title="Is my data secure?">
<p>
Yes. Echo stores card images in encrypted object storage, encrypts
integration credentials at rest, and transports everything over TLS.
Access is scoped to your organization &mdash; nobody outside your
team can see your cards. See the{" "}
<a href="/privacy">Privacy Policy</a> for full details.
</p>
</DocSection>
<DocSection title="How long are card images retained?">
<p>
By default, cards are retained for 2 years. Administrators can change
the retention window under{" "}
<strong>Settings &rarr; Organization</strong>, or delete individual
cards at any time.
</p>
</DocSection>
<DocSection title="Can I use Echo for non-English cards?">
<p>
Echo supports cards in most Latin-script languages out of the box
(Spanish, Portuguese, French, German, etc.). For non-Latin scripts
(Korean, Mandarin, Arabic), reach out &mdash; we can enable a
different extraction model for your organization.
</p>
</DocSection>
<DocSection title="What does it cost?">
<p>
Echo is priced per card processed, with a generous monthly free tier
for small churches. See <a href="/pricing">the pricing page</a> for
current tiers. Billing questions? Use the Contact button below &mdash;
it opens a billing-tagged ticket.
</p>
</DocSection>
<DocSection title="Can multiple people review the same batch?">
<p>
Yes. When you upload, cards go into the review queue for your whole
org. Any reviewer or editor can pick up a card; Echo locks it while
someone&rsquo;s editing to prevent conflicts.
</p>
</DocSection>
<DocSection title="Why did a card end up in 'Failed'?">
<p>
Usually because the file was too blurry, too dark, or didn&rsquo;t
look like a form at all (e.g., a blank envelope). The original file
is kept &mdash; you can retry after adjusting the scan, or re-upload
a better version. The{" "}
<a href="/docs/troubleshooting">Troubleshooting guide</a> has more
detail.
</p>
</DocSection>
<DocSection title="How do I cancel my subscription?">
<p>
Go to <strong>Settings &rarr; Organization &rarr; Billing</strong> and
click <strong>Cancel plan</strong>. Your data stays accessible until
the end of the billing period.
</p>
</DocSection>
<DocContactCta label="Didn't find an answer? Reach out and we'll help." />
</DocArticle>
);
}

View file

@ -0,0 +1,109 @@
import { FileText } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Forms & Templates — Echo Help",
};
export default function FormsTemplatesPage() {
return (
<DocArticle
title="Forms &amp; Templates"
description="Define what fields Echo should pull from each card, map them to your data model, and validate before going live."
icon={FileText}
>
<DocSection title="What is a form template?">
<p>
A <strong>form template</strong> is a schema that describes the
fields on a physical response card. When Echo processes an uploaded
image or PDF, it uses your active template to decide what to extract
(e.g., <em>Name</em>, <em>Email</em>, <em>First-time visitor</em>,
etc.) and where to put the values.
</p>
<p>
You can have as many templates as you want &mdash; one per card type,
event, or campaign.
</p>
</DocSection>
<DocSection title="Creating a template">
<DocSteps>
<DocStep index={1} title="Open the template editor">
Go to <strong>Settings &rarr; Form Templates</strong> and click{" "}
<strong>New template</strong>.
</DocStep>
<DocStep index={2} title="Upload a sample card">
Upload a clean, filled-in sample. Echo uses it for the preview and
to test extraction later.
</DocStep>
<DocStep index={3} title="Add fields">
For each piece of data on the card, add a field with:
<ul className="ml-6 mt-2 list-disc space-y-1">
<li>
<strong>Label</strong> &mdash; human-readable name (e.g.,{" "}
&ldquo;Preferred Name&rdquo;).
</li>
<li>
<strong>Type</strong> &mdash; text, email, phone, checkbox,
choice, or multi-line.
</li>
<li>
<strong>Mapping</strong> &mdash; which person attribute this
field writes to (e.g.,{" "}
<code>person.email</code>, <code>person.visitType</code>).
</li>
<li>
<strong>Required?</strong> &mdash; required fields block{" "}
<em>Confirm</em> until filled.
</li>
</ul>
</DocStep>
<DocStep index={4} title="Test extraction">
Hit <strong>Test</strong>. Echo runs OCR on your sample and shows
exactly what it would write to each field. Iterate on labels and
mappings until the output is correct.
</DocStep>
<DocStep index={5} title="Publish">
Mark the template as <strong>Active</strong> and set it as the
default (or assign per upload source).
</DocStep>
</DocSteps>
</DocSection>
<DocSection title="Field mapping tips">
<DocCallout tone="tip" title="Match field labels to what's printed">
The AI uses your field labels as hints. If your card says &ldquo;Best
way to reach you?&rdquo;, name that field the same way &mdash; it
improves accuracy dramatically.
</DocCallout>
<DocCallout tone="tip" title="Use choice fields for checkboxes">
For rows of checkboxes (e.g., &ldquo;First time / Returning /
Member&rdquo;), use the <strong>Choice</strong> field type and list
the options exactly as they appear on the card.
</DocCallout>
</DocSection>
<DocSection title="Handling multiple card designs">
<p>
If you have more than one card design in rotation (e.g., visitor cards
vs. prayer request cards), create one template per design and assign
the right template at upload time or per upload source. Echo does{" "}
<em>not</em> auto-detect which template fits a given card.
</p>
</DocSection>
<DocNext
href="/docs/integrations"
title="Integrations"
description="Push confirmed data to Planning Center, Google Sheets, Monday, and more."
/>
</DocArticle>
);
}

View file

@ -0,0 +1,110 @@
import { Rocket } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Getting Started — Echo Help",
};
export default function GettingStartedPage() {
return (
<DocArticle
title="Getting Started"
description="Go from signup to your first processed response card in about 10 minutes."
icon={Rocket}
>
<DocSection title="1. Create your account">
<DocSteps>
<DocStep index={1} title="Sign up">
Head to <code>/signup</code> and create an account with your work
email. If your church already uses Stillwell SSO, click{" "}
<strong>Sign in with SSO</strong> on the login page.
</DocStep>
<DocStep index={2} title="Verify your email">
Check your inbox for a verification email from{" "}
<code>mars@noreply.stillwell.cloud</code> and click the link. You
must verify before uploading cards.
</DocStep>
<DocStep index={3} title="Create your organization">
After verifying, you&rsquo;ll be guided through onboarding. Give your
organization a name (usually your church or ministry name) and
confirm.
</DocStep>
</DocSteps>
</DocSection>
<DocSection title="2. Invite your team">
<p>
Go to <strong>Settings &rarr; Team Members</strong> and click{" "}
<strong>Invite</strong>. Choose a role for each teammate:
</p>
<ul className="ml-6 list-disc space-y-1">
<li>
<strong>Admin</strong> &mdash; full access including billing and
settings.
</li>
<li>
<strong>Editor</strong> &mdash; can upload, review, and assign
cards.
</li>
<li>
<strong>Reviewer</strong> &mdash; can review and confirm OCR
results, but not change settings.
</li>
<li>
<strong>Viewer</strong> &mdash; read-only access to cards and
reports.
</li>
</ul>
<p>
Invites expire after 7 days. You can revoke an unused invitation from
the same page.
</p>
</DocSection>
<DocSection title="3. Configure a form template">
<p>
Echo uses <strong>form templates</strong> to know what fields to
extract from each card. Head to{" "}
<strong>Settings &rarr; Form Templates</strong> and either start from
a starter template or build your own.
</p>
<DocCallout tone="tip" title="Tip">
The default &ldquo;Response Card&rdquo; template handles name, email,
phone, and attendance &mdash; a great starting point for most
churches.
</DocCallout>
</DocSection>
<DocSection title="4. Upload your first card">
<DocSteps>
<DocStep index={1} title="Click Upload">
Use the <strong>Upload</strong> button in the top bar, or press{" "}
<code>&#8984;K</code> and choose <strong>Upload Documents</strong>.
</DocStep>
<DocStep index={2} title="Pick files">
Drag &amp; drop PDFs, JPGs, or PNGs. You can upload up to 50 files
at a time.
</DocStep>
<DocStep index={3} title="Review the extraction">
Echo processes each card in the background. Head to{" "}
<strong>Response Cards</strong> to review extracted fields, correct
anything, and assign the card to a person.
</DocStep>
</DocSteps>
</DocSection>
<DocNext
href="/docs/uploading-cards"
title="Uploading Cards"
description="Learn about every way to get cards into Echo — manual, email, FTP, and more."
/>
</DocArticle>
);
}

View file

@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BookOpen,
Rocket,
Upload,
FileText,
Plug,
BarChart3,
HelpCircle,
Wrench,
LifeBuoy,
ArrowRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { openContactSupport } from "@/components/support/contact-support-dialog";
const SECTIONS = [
{ href: "/docs", label: "Overview", icon: BookOpen, exact: true },
{ href: "/docs/getting-started", label: "Getting Started", icon: Rocket },
{ href: "/docs/uploading-cards", label: "Uploading Cards", icon: Upload },
{ href: "/docs/forms-templates", label: "Forms & Templates", icon: FileText },
{ href: "/docs/integrations", label: "Integrations", icon: Plug },
{ href: "/docs/reports-exports", label: "Reports & Exports", icon: BarChart3 },
{ href: "/docs/faq", label: "FAQ", icon: HelpCircle },
{ href: "/docs/troubleshooting", label: "Troubleshooting", icon: Wrench },
];
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<div className="flex flex-col gap-6 lg:flex-row">
<nav className="flex gap-1 overflow-x-auto lg:sticky lg:top-24 lg:h-fit lg:w-60 lg:shrink-0 lg:flex-col">
{SECTIONS.map((section) => {
const active = section.exact
? pathname === section.href
: pathname === section.href ||
pathname.startsWith(section.href + "/");
const Icon = section.icon;
return (
<Link
key={section.href}
href={section.href}
className={cn(
"flex items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
>
<Icon className="size-4 shrink-0" />
{section.label}
</Link>
);
})}
<div className="mt-4 rounded-xl border border-border/60 bg-muted/30 p-4 lg:max-w-60">
<div className="mb-2 flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-md bg-primary/10 text-primary">
<LifeBuoy className="size-3.5" />
</div>
<p className="text-sm font-semibold">Still stuck?</p>
</div>
<p className="mb-3 text-xs leading-relaxed text-muted-foreground">
Can't find what you need? Our team is happy to help.
</p>
<Button size="sm" className="w-full" onClick={openContactSupport}>
Contact support
<ArrowRight className="size-3.5" />
</Button>
</div>
</nav>
<div className="min-w-0 flex-1">{children}</div>
</div>
);
}

View file

@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BookOpen,
Rocket,
Upload,
FileText,
Plug,
BarChart3,
HelpCircle,
Wrench,
Search,
ArrowRight,
LifeBuoy,
type LucideIcon,
} from "lucide-react";
import { Header } from "@/components/layout/header";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { openContactSupport } from "@/components/support/contact-support-dialog";
type Topic = {
href: string;
title: string;
description: string;
icon: LucideIcon;
keywords: string[];
};
const TOPICS: Topic[] = [
{
href: "/docs/getting-started",
title: "Getting Started",
description:
"Create your account, set up your organization, invite your team, and upload your first response card.",
icon: Rocket,
keywords: [
"signup",
"onboarding",
"organization",
"invite",
"team",
"first upload",
"welcome",
"setup",
],
},
{
href: "/docs/uploading-cards",
title: "Uploading Cards",
description:
"Manual uploads, scanner/email ingestion, FTP watchers, supported file types, and the OCR lifecycle.",
icon: Upload,
keywords: [
"upload",
"scan",
"email watch",
"ftp",
"pdf",
"jpg",
"png",
"ocr",
"processing",
"queue",
],
},
{
href: "/docs/forms-templates",
title: "Forms & Templates",
description:
"Build form templates, map fields, test extraction, and handle edge cases.",
icon: FileText,
keywords: [
"form",
"template",
"fields",
"mapping",
"extraction",
"ai",
"testing",
],
},
{
href: "/docs/integrations",
title: "Integrations",
description:
"Connect Planning Center, Google Sheets, Monday, webhooks, and more to sync your data automatically.",
icon: Plug,
keywords: [
"integrations",
"planning center",
"pco",
"google sheets",
"monday",
"webhook",
"oauth",
"sync",
"export",
],
},
{
href: "/docs/reports-exports",
title: "Reports & Exports",
description:
"Generate reports, export CSVs, and push data to connected integrations.",
icon: BarChart3,
keywords: [
"reports",
"export",
"csv",
"download",
"analytics",
"charts",
],
},
{
href: "/docs/faq",
title: "FAQ",
description:
"Quick answers to the most common questions about billing, accuracy, security, and limits.",
icon: HelpCircle,
keywords: [
"faq",
"questions",
"billing",
"pricing",
"accuracy",
"security",
"privacy",
"limits",
],
},
{
href: "/docs/troubleshooting",
title: "Troubleshooting",
description:
"Fix OCR accuracy issues, email-watch failures, integration auth errors, and stuck jobs.",
icon: Wrench,
keywords: [
"troubleshoot",
"error",
"stuck",
"fail",
"broken",
"not working",
"fix",
"debug",
],
},
];
export default function DocsHomePage() {
const [query, setQuery] = React.useState("");
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return TOPICS;
return TOPICS.filter((t) => {
if (t.title.toLowerCase().includes(q)) return true;
if (t.description.toLowerCase().includes(q)) return true;
return t.keywords.some((k) => k.includes(q));
});
}, [query]);
return (
<div className="space-y-6">
<Header
title="Help & Docs"
description="Guides, references, and answers to help you get the most out of Echo."
icon={BookOpen}
/>
<Card className="glass-card">
<CardContent className="p-5">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search the docs…"
className="pl-9"
aria-label="Search the documentation"
/>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Try searching for &ldquo;upload&rdquo;, &ldquo;Planning Center&rdquo;,
or &ldquo;pricing&rdquo;.
</p>
</CardContent>
</Card>
{filtered.length === 0 ? (
<Card className="glass-card">
<CardContent className="p-8 text-center">
<p className="text-sm text-muted-foreground">
No matching topics found for &ldquo;{query}&rdquo;.
</p>
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={openContactSupport}
>
<LifeBuoy className="size-3.5" />
Ask support
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{filtered.map((topic) => {
const Icon = topic.icon;
return (
<Link key={topic.href} href={topic.href} className="group">
<Card className="h-full transition-all hover:border-primary/40 hover:shadow-md">
<CardContent className="p-5">
<div className="mb-3 flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" />
</div>
<h3 className="text-base font-semibold tracking-tight">
{topic.title}
</h3>
<ArrowRight className="ml-auto size-4 text-muted-foreground transition-all group-hover:translate-x-0.5 group-hover:text-primary" />
</div>
<p className="text-sm leading-relaxed text-muted-foreground">
{topic.description}
</p>
</CardContent>
</Card>
</Link>
);
})}
</div>
)}
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex flex-col items-start gap-3 p-5 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/15 text-primary">
<LifeBuoy className="size-4" />
</div>
<div>
<p className="text-sm font-semibold">Still have questions?</p>
<p className="text-xs text-muted-foreground">
Send us a message and we&rsquo;ll get back to you shortly.
</p>
</div>
</div>
<Button size="sm" onClick={openContactSupport}>
Contact support
<ArrowRight className="size-3.5" />
</Button>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { BarChart3 } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Reports & Exports — Echo Help",
};
export default function ReportsExportsPage() {
return (
<DocArticle
title="Reports &amp; Exports"
description="Slice your card data, export CSVs, and push the results to your connected tools."
icon={BarChart3}
>
<DocSection title="Built-in reports">
<p>
The <strong>Reports</strong> page gives you a running view of:
</p>
<ul className="ml-6 list-disc space-y-1">
<li>New vs. returning visitors over time</li>
<li>Card volume per collection day</li>
<li>Top locations / services</li>
<li>Reviewer throughput &mdash; who confirmed how many cards</li>
</ul>
<p>
Use the filter bar at the top to scope any report by date range,
collection day, location, or assignee.
</p>
</DocSection>
<DocSection title="CSV export">
<DocSteps>
<DocStep index={1} title="Filter the list">
On the <strong>Response Cards</strong> page, filter to the exact
set you want to export.
</DocStep>
<DocStep index={2} title="Click Export CSV">
Use the Export button in the toolbar. The CSV includes every mapped
field plus metadata (upload date, reviewer, collection day).
</DocStep>
<DocStep index={3} title="Import into your CRM">
Most ChMS and CRM tools accept CSV uploads. Check their docs for
column-name requirements.
</DocStep>
</DocSteps>
<DocCallout tone="tip">
Need a recurring export? Set up the{" "}
<strong>CSV Export</strong> integration to drop a CSV to FTP, email,
or a webhook on a schedule.
</DocCallout>
</DocSection>
<DocSection title="Pushing to integrations">
<p>
When a card is <strong>Confirmed</strong>, Echo can push it to any
connected integration. Configure the trigger per integration (every
confirm vs. batched daily) under{" "}
<strong>Settings &rarr; Integrations</strong>.
</p>
<p>
For a full walkthrough of each provider, see the{" "}
<a href="/docs/integrations">Integrations docs</a>.
</p>
</DocSection>
<DocNext
href="/docs/faq"
title="FAQ"
description="Quick answers to the most common questions."
/>
</DocArticle>
);
}

View file

@ -0,0 +1,153 @@
import { Wrench } from "lucide-react";
import {
DocArticle,
DocSection,
DocCallout,
DocContactCta,
} from "@/components/support/doc-article";
export const metadata = {
title: "Troubleshooting — Echo Help",
};
export default function TroubleshootingPage() {
return (
<DocArticle
title="Troubleshooting"
description="Common problems, quick fixes, and how to get unstuck."
icon={Wrench}
>
<DocSection title="OCR accuracy is lower than I expected">
<ul className="ml-6 list-disc space-y-2">
<li>
<strong>Scan at 300 DPI or higher.</strong> Lower resolutions blur
handwriting and drop accuracy fast.
</li>
<li>
<strong>Scan in color or grayscale</strong>, not bitonal B&amp;W.
Bitonal drops subtle handwriting strokes.
</li>
<li>
<strong>Make sure the form template matches.</strong> The AI uses
your field labels as hints; if the template is wrong, every card
will be off.
</li>
<li>
<strong>Crop out clutter.</strong> If your scanner includes a bunch
of blank space or overlap from the next card, extraction can
wander.
</li>
</ul>
</DocSection>
<DocSection title="Email-watch isn't picking up new mail">
<ul className="ml-6 list-disc space-y-2">
<li>
Confirm the inbox status on <strong>Settings &rarr; Upload Sources
&rarr; Email</strong> is <em>Connected</em>. If it shows{" "}
<em>Error</em>, the credentials are likely stale.
</li>
<li>
For Gmail, make sure you&rsquo;re using an{" "}
<strong>App Password</strong>, not your main account password.
Regular passwords will fail auth.
</li>
<li>
Check that IMAP access is enabled in the source account&rsquo;s
settings.
</li>
<li>
Verify the sender isn&rsquo;t being filtered into Spam &mdash; Echo
only watches the Inbox folder by default.
</li>
</ul>
</DocSection>
<DocSection title="FTP watcher says 'connection refused'">
<ul className="ml-6 list-disc space-y-2">
<li>
Double-check the host, port, and protocol (FTP vs. SFTP) match what
your scanner uses.
</li>
<li>
If your FTP is only reachable from inside your church&rsquo;s
network, expose it via SFTP or switch to email-watch.
</li>
<li>
Some FTP servers require <em>passive mode</em>. Toggle it in the
source settings if the initial listing hangs.
</li>
</ul>
</DocSection>
<DocSection title="An integration stopped syncing">
<ul className="ml-6 list-disc space-y-2">
<li>
Go to <strong>Settings &rarr; Integrations</strong> and check the{" "}
<em>Status</em> badge. A red <em>Auth expired</em> badge means
OAuth needs reconnecting.
</li>
<li>
Click the integration, then <strong>Reconnect</strong>. You may
need to sign into the provider (Google, Planning Center, etc.)
again.
</li>
<li>
For webhook destinations, inspect the latest delivery from{" "}
<strong>Settings &rarr; Integrations &rarr; Webhook &rarr; Recent
deliveries</strong>.
</li>
</ul>
</DocSection>
<DocSection title="Cards are stuck on 'Processing'">
<p>
This almost always means the background job queue is backed up.
Usually it clears within a few minutes. If a card stays in
Processing for more than an hour:
</p>
<ul className="ml-6 list-disc space-y-2">
<li>
Open the card, click <strong>More</strong> &rarr;{" "}
<strong>Retry processing</strong>.
</li>
<li>
If the retry also gets stuck, the file may be corrupted &mdash;
delete and re-upload.
</li>
<li>
If that still fails, reach out to support and include the card
ID.
</li>
</ul>
</DocSection>
<DocSection title="I can't sign in">
<ul className="ml-6 list-disc space-y-2">
<li>
Use the <strong>Forgot password</strong> link on the sign-in page
to reset.
</li>
<li>
If your church uses SSO, sign in with the{" "}
<strong>Sign in with SSO</strong> button &mdash; the
email/password flow won&rsquo;t work for SSO-only accounts.
</li>
<li>
If your email isn&rsquo;t verified yet, check your inbox (and
spam) for the verification email from{" "}
<code>mars@noreply.stillwell.cloud</code>.
</li>
</ul>
</DocSection>
<DocCallout tone="info" title="Have a card ID? Include it">
When you contact support about a specific card, mention the card ID
(shown in the URL when you open a card). It lets us jump directly to
the exact run.
</DocCallout>
<DocContactCta label="Still stuck? Describe what you're seeing and we'll dig in." />
</DocArticle>
);
}

View file

@ -0,0 +1,129 @@
import { Upload } from "lucide-react";
import {
DocArticle,
DocSection,
DocSteps,
DocStep,
DocCallout,
DocNext,
} from "@/components/support/doc-article";
export const metadata = {
title: "Uploading Cards — Echo Help",
};
export default function UploadingCardsPage() {
return (
<DocArticle
title="Uploading Cards"
description="Everything about getting response cards into Echo — manual uploads, email ingestion, FTP watchers, and the OCR processing lifecycle."
icon={Upload}
>
<DocSection title="Supported file types">
<ul className="ml-6 list-disc space-y-1">
<li>
<strong>PDF</strong> &mdash; single-page or multi-page. Multi-page
PDFs are split into one card per page.
</li>
<li>
<strong>Images</strong> &mdash; JPG, JPEG, PNG, GIF, TIFF, BMP.
</li>
<li>
Maximum file size is <strong>25 MB</strong> per file,{" "}
<strong>50 files</strong> per upload batch.
</li>
</ul>
</DocSection>
<DocSection title="Manual upload">
<DocSteps>
<DocStep index={1} title="Open the Upload dialog">
Click the <strong>Upload</strong> button in the top bar, or press{" "}
<code>&#8984;K</code> &rarr; <strong>Upload Documents</strong>.
</DocStep>
<DocStep index={2} title="Pick a collection day (optional)">
Tagging the upload with a collection day (e.g., &ldquo;Easter
Sunday&rdquo;) makes later reporting easier.
</DocStep>
<DocStep index={3} title="Drag &amp; drop files">
Drop files anywhere on the dialog. Echo uploads them to storage and
queues each for OCR immediately.
</DocStep>
</DocSteps>
</DocSection>
<DocSection title="Email ingestion (email-watch)">
<p>
Echo can monitor an email inbox and automatically ingest any cards
sent as attachments. Configure it under{" "}
<strong>Settings &rarr; Upload Sources &rarr; Email</strong>:
</p>
<DocSteps>
<DocStep index={1} title="Create a dedicated inbox">
Ideally a Gmail or IMAP-capable inbox only used for Echo (e.g.,{" "}
<code>cards@yourchurch.org</code>).
</DocStep>
<DocStep index={2} title="Paste IMAP credentials">
Echo stores the credentials encrypted and uses a long-lived IMAP
IDLE connection to pick up new mail within seconds.
</DocStep>
<DocStep index={3} title="Send a test">
Forward a card to the watched address. It should appear in{" "}
<strong>Response Cards</strong> within a minute.
</DocStep>
</DocSteps>
<DocCallout tone="warning" title="Use an app password">
For Gmail, create an App Password &mdash; your main account password
will not work.
</DocCallout>
</DocSection>
<DocSection title="FTP watchers">
<p>
If your scanner drops files to an FTP/SFTP location, Echo can poll it
and pull new files in automatically. Configure under{" "}
<strong>Settings &rarr; Upload Sources &rarr; FTP</strong>. Both FTP
and SFTP are supported.
</p>
<DocCallout tone="tip">
Set your scanner to drop each scan into its own subfolder named for
the date &mdash; Echo tags the upload with that subfolder name so you
can group scans later.
</DocCallout>
</DocSection>
<DocSection title="The OCR processing lifecycle">
<p>Every uploaded file moves through these states:</p>
<ul className="ml-6 list-disc space-y-1">
<li>
<strong>Uploaded</strong> &mdash; file is stored, waiting for a
worker.
</li>
<li>
<strong>Processing</strong> &mdash; AI is extracting fields against
your active form template.
</li>
<li>
<strong>Needs Review</strong> &mdash; extraction succeeded; a human
should confirm unclear fields.
</li>
<li>
<strong>Confirmed</strong> &mdash; reviewer has signed off; card is
ready for export/integration push.
</li>
<li>
<strong>Failed</strong> &mdash; extraction couldn&rsquo;t run (bad
image, unreadable handwriting, etc.). The file is retained so you
can retry.
</li>
</ul>
</DocSection>
<DocNext
href="/docs/forms-templates"
title="Forms & Templates"
description="Build the templates that tell Echo what to extract from each card."
/>
</DocArticle>
);
}

View file

@ -156,7 +156,7 @@ export default function IntegrationsPage() {
on our Enterprise plan.
</p>
<Link
href="mailto:sales@echoocr.com"
href="mailto:support@stillwell.cloud?subject=Integration%20request"
className="inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline"
>
Request an integration

View file

@ -83,7 +83,7 @@ const tiers = [
"Self-hosted deployment option",
],
cta: "Contact Sales",
ctaHref: "mailto:sales@echoocr.com",
ctaHref: "mailto:support@stillwell.cloud?subject=Sales%20inquiry",
highlighted: false,
},
];

View file

@ -390,8 +390,8 @@ export default function PrivacyPolicyPage() {
<p>
To request deletion of your account and all associated data, please contact us
at{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
. We will process deletion requests within 30 days.
</p>
@ -435,8 +435,8 @@ export default function PrivacyPolicyPage() {
</p>
<p>
To exercise your rights regarding your Echo account, contact us at{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
.
</p>
@ -454,8 +454,8 @@ export default function PrivacyPolicyPage() {
<p>
If you believe we have inadvertently collected information from a child under 13
without appropriate consent, please contact us immediately at{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
.
</p>
@ -490,14 +490,14 @@ export default function PrivacyPolicyPage() {
<ul className="list-none space-y-1 ml-2">
<li>
Email:{" "}
<a href="mailto:privacy@echoocr.com" className="underline hover:text-foreground">
privacy@echoocr.com
<a href="mailto:support@stillwell.cloud?subject=Privacy%20inquiry" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
</li>
<li>
General support:{" "}
<a href="mailto:support@echoocr.com" className="underline hover:text-foreground">
support@echoocr.com
<a href="mailto:support@stillwell.cloud" className="underline hover:text-foreground">
support@stillwell.cloud
</a>
</li>
</ul>

View file

@ -377,10 +377,10 @@ export default function TermsOfServicePage() {
Either party may terminate these Terms at any time. You may stop using the
Service and delete your account by contacting{" "}
<a
href="mailto:support@echoocr.com"
href="mailto:support@stillwell.cloud"
className="underline hover:text-foreground"
>
support@echoocr.com
support@stillwell.cloud
</a>
.
</p>
@ -423,21 +423,21 @@ export default function TermsOfServicePage() {
</p>
<ul className="list-none space-y-1 ml-2">
<li>
Email:{" "}
Legal:{" "}
<a
href="mailto:legal@echoocr.com"
href="mailto:support@stillwell.cloud?subject=Legal%20inquiry"
className="underline hover:text-foreground"
>
legal@echoocr.com
support@stillwell.cloud
</a>
</li>
<li>
General support:{" "}
<a
href="mailto:support@echoocr.com"
href="mailto:support@stillwell.cloud"
className="underline hover:text-foreground"
>
support@echoocr.com
support@stillwell.cloud
</a>
</li>
</ul>

View file

@ -437,7 +437,7 @@ const tiers = [
"99.9% uptime SLA",
],
cta: "Contact Sales",
ctaHref: "mailto:sales@echoocr.com",
ctaHref: "mailto:support@stillwell.cloud?subject=Sales%20inquiry",
highlighted: false,
},
];

View file

@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireAuth, RoleError } from "@/lib/auth";
import { prisma } from "@/lib/db";
import {
sendSupportTicketEmail,
type SupportTicketCategory,
} from "@/lib/email-sender";
const supportSchema = z.object({
subject: z.string().trim().min(3).max(120),
category: z.enum(["bug", "question", "feature", "billing", "other"]),
message: z.string().trim().min(10).max(5000),
pageUrl: z.string().trim().max(500).optional().nullable(),
});
// Simple in-memory rate limit: max 5 submissions per 10 minutes per user.
const WINDOW_MS = 10 * 60 * 1000;
const MAX_PER_WINDOW = 5;
const rateBucket = new Map<string, number[]>();
function rateLimit(key: string): { ok: boolean; retryAfterSeconds?: number } {
const now = Date.now();
const timestamps = rateBucket.get(key) || [];
const recent = timestamps.filter((t) => now - t < WINDOW_MS);
if (recent.length >= MAX_PER_WINDOW) {
const oldest = recent[0];
const retry = Math.ceil((WINDOW_MS - (now - oldest)) / 1000);
return { ok: false, retryAfterSeconds: retry };
}
recent.push(now);
rateBucket.set(key, recent);
return { ok: true };
}
export async function POST(req: NextRequest) {
try {
const user = await requireAuth();
const limit = rateLimit(`support:${user.id}`);
if (!limit.ok) {
return NextResponse.json(
{
error:
"You've submitted too many support requests. Please try again shortly.",
retryAfterSeconds: limit.retryAfterSeconds,
},
{ status: 429 }
);
}
const body = await req.json().catch(() => null);
const parsed = supportSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid support request",
details: parsed.error.flatten(),
},
{ status: 400 }
);
}
const { subject, category, message, pageUrl } = parsed.data;
// Resolve org name if the user has one
let orgName: string | null = null;
if (user.orgId) {
const org = await prisma.organization.findUnique({
where: { id: user.orgId },
select: { name: true },
});
orgName = org?.name ?? null;
}
const userAgent = req.headers.get("user-agent");
await sendSupportTicketEmail({
subject,
message,
category: category as SupportTicketCategory,
userEmail: user.email,
userName: user.displayName || user.username || null,
userId: user.id,
orgId: user.orgId || null,
orgName,
pageUrl: pageUrl || null,
userAgent,
appEnv: process.env.NEXT_PUBLIC_ENV || null,
});
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof RoleError) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
console.error("[api/support] Error:", error);
return NextResponse.json(
{ error: "Failed to submit support request" },
{ status: 500 }
);
}
}

View file

@ -14,7 +14,10 @@ import {
UserPlus,
FileText,
Search,
BookOpen,
LifeBuoy,
} from "lucide-react";
import { openContactSupport } from "@/components/support/contact-support-dialog";
const navItems = [
{ label: "Dashboard", href: "/", icon: LayoutDashboard, group: "Navigate" },
@ -24,11 +27,13 @@ const navItems = [
{ label: "Reports", href: "/reports", icon: BarChart3, group: "Navigate" },
{ label: "Settings", href: "/settings", icon: Settings, group: "Navigate" },
{ label: "Form Templates", href: "/settings/form-templates", icon: FileText, group: "Navigate" },
{ label: "Help Center", href: "/docs", icon: BookOpen, group: "Navigate" },
];
const actionItems = [
{ label: "Upload Documents", icon: Upload, group: "Actions", action: "upload" },
{ label: "Create Person", icon: UserPlus, group: "Actions", action: "create-person" },
{ label: "Contact Support", icon: LifeBuoy, group: "Actions", action: "contact-support" },
];
export function CommandPalette() {
@ -53,6 +58,8 @@ export function CommandPalette() {
window.dispatchEvent(new CustomEvent("open-upload-modal"));
} else if (action === "create-person") {
router.push("/people?new=true");
} else if (action === "contact-support") {
openContactSupport();
}
},
[router]

View file

@ -7,6 +7,7 @@ import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sideba
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
import { CommandPalette } from "@/components/command-palette";
import { UploadModal } from "@/components/cards/upload-modal";
import { ContactSupportDialog } from "@/components/support/contact-support-dialog";
function ShellContent({ children }: { children: React.ReactNode }) {
const { collapsed } = useSidebar();
@ -40,6 +41,7 @@ function ShellContent({ children }: { children: React.ReactNode }) {
onOpenChange={setUploadOpen}
initialFiles={uploadFiles.length > 0 ? uploadFiles : undefined}
/>
<ContactSupportDialog />
<main
id="main-content"
className={cn(

View file

@ -13,6 +13,7 @@ import {
Settings,
ChevronsLeft,
ChevronsRight,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@ -37,6 +38,7 @@ const navItems = [
];
const bottomItems = [
{ href: "/docs", label: "Help & Docs", icon: BookOpen },
{ href: "/settings", label: "Settings", icon: Settings },
];

View file

@ -13,6 +13,7 @@ import {
User,
UserCircle,
LifeBuoy,
BookOpen,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -34,6 +35,7 @@ import {
import { useUserProfile } from "@/lib/user-profile";
import { isAdminRole } from "@/lib/permissions";
import { NotificationCenter } from "@/components/notifications/notification-center";
import { openContactSupport } from "@/components/support/contact-support-dialog";
export function TopBar() {
const { theme, setTheme } = useTheme();
@ -154,11 +156,13 @@ export function TopBar() {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => window.open("mailto:support@echoocr.app", "_blank")}
>
<DropdownMenuItem render={<Link href="/docs" />}>
<BookOpen className="size-4" />
Help Center
</DropdownMenuItem>
<DropdownMenuItem onClick={openContactSupport}>
<LifeBuoy className="size-4" />
Support
Contact Support
</DropdownMenuItem>
<DropdownMenuItem onClick={cycleTheme}>
<Sun className="size-4 dark:hidden" />

View file

@ -31,8 +31,8 @@ export function MarketingFooter() {
<div>
<h4 className="text-sm font-semibold mb-4">Company</h4>
<ul className="space-y-2.5 text-sm text-muted-foreground">
<li><a href="mailto:support@echoocr.com" className="hover:text-foreground transition-colors">Support</a></li>
<li><a href="mailto:sales@echoocr.com" className="hover:text-foreground transition-colors">Sales</a></li>
<li><a href="mailto:support@stillwell.cloud" className="hover:text-foreground transition-colors">Support</a></li>
<li><a href="mailto:support@stillwell.cloud?subject=Sales%20inquiry" className="hover:text-foreground transition-colors">Sales</a></li>
<li><Link href="/privacy" className="hover:text-foreground transition-colors">Privacy Policy</Link></li>
<li><Link href="/terms" className="hover:text-foreground transition-colors">Terms of Service</Link></li>
</ul>

View file

@ -0,0 +1,259 @@
"use client";
import * as React from "react";
import { usePathname } from "next/navigation";
import { toast } from "sonner";
import { LifeBuoy, Loader2, Send } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useUserProfile } from "@/lib/user-profile";
export const OPEN_SUPPORT_MODAL_EVENT = "open-support-modal";
type Category = "bug" | "question" | "feature" | "billing" | "other";
const CATEGORY_OPTIONS: { value: Category; label: string; hint: string }[] = [
{ value: "bug", label: "Bug report", hint: "Something isn't working" },
{ value: "question", label: "Question", hint: "How do I…?" },
{ value: "feature", label: "Feature request", hint: "I wish Echo could…" },
{ value: "billing", label: "Billing", hint: "Plans, invoices, payment" },
{ value: "other", label: "Other", hint: "Anything else" },
];
const SUBJECT_MIN = 3;
const SUBJECT_MAX = 120;
const MESSAGE_MIN = 10;
const MESSAGE_MAX = 5000;
export function ContactSupportDialog() {
const { profile, isAuthenticated } = useUserProfile();
const pathname = usePathname();
const [open, setOpen] = React.useState(false);
const [category, setCategory] = React.useState<Category>("question");
const [subject, setSubject] = React.useState("");
const [message, setMessage] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
React.useEffect(() => {
const handler = () => setOpen(true);
window.addEventListener(OPEN_SUPPORT_MODAL_EVENT, handler);
return () => window.removeEventListener(OPEN_SUPPORT_MODAL_EVENT, handler);
}, []);
React.useEffect(() => {
if (!open) {
setCategory("question");
setSubject("");
setMessage("");
setSubmitting(false);
}
}, [open]);
const canSubmit =
subject.trim().length >= SUBJECT_MIN &&
subject.trim().length <= SUBJECT_MAX &&
message.trim().length >= MESSAGE_MIN &&
message.trim().length <= MESSAGE_MAX &&
!submitting;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setSubmitting(true);
try {
const pageUrl =
typeof window !== "undefined"
? `${window.location.origin}${pathname || window.location.pathname}`
: pathname || "";
const res = await fetch("/api/support", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
subject: subject.trim(),
category,
message: message.trim(),
pageUrl,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (res.status === 429) {
toast.error(
data.error ||
"You've submitted too many support requests. Please try again shortly."
);
} else if (res.status === 401) {
toast.error("Please sign in to contact support.");
} else {
toast.error(
data.error || "Couldn't send your message. Please try again."
);
}
setSubmitting(false);
return;
}
toast.success("Support request sent", {
description:
"We've received your message and will reply to " +
(profile.email || "your email") +
" shortly.",
});
setOpen(false);
} catch (err) {
console.error("[contact-support] submit failed", err);
toast.error("Couldn't send your message. Please try again.");
setSubmitting(false);
}
}
if (!isAuthenticated) {
// Fall back to mailto for logged-out users — the dialog depends on session.
return null;
}
const selectedCategory = CATEGORY_OPTIONS.find((c) => c.value === category);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
<LifeBuoy className="size-4" />
</div>
<div className="flex-1">
<DialogTitle>Contact support</DialogTitle>
<DialogDescription>
Our team will reply directly to{" "}
<span className="font-medium text-foreground">
{profile.email || "your account email"}
</span>
.
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="support-category">What's this about?</Label>
<Select
value={category}
onValueChange={(v) => v && setCategory(v as Category)}
>
<SelectTrigger id="support-category" className="w-full rounded-lg">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="font-medium">{opt.label}</span>
<span className="ml-1.5 text-xs text-muted-foreground">
{opt.hint}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="support-subject">Subject</Label>
<Input
id="support-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={
selectedCategory?.value === "bug"
? "Short description of the bug"
: "One-line summary"
}
maxLength={SUBJECT_MAX}
required
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label htmlFor="support-message">Message</Label>
<span className="text-xs text-muted-foreground">
{message.length}/{MESSAGE_MAX}
</span>
</div>
<Textarea
id="support-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={
category === "bug"
? "What did you try? What happened? What did you expect?"
: "Share as much detail as you can."
}
maxLength={MESSAGE_MAX}
className="min-h-32"
required
/>
</div>
<div className="rounded-lg border border-border/50 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
We'll attach your account details, current page, and browser info
automatically so we can help faster.
</div>
<DialogFooter>
<DialogClose
render={
<Button type="button" variant="outline" disabled={submitting} />
}
>
Cancel
</DialogClose>
<Button type="submit" disabled={!canSubmit}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Sending
</>
) : (
<>
<Send className="size-4" />
Send message
</>
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function openContactSupport() {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent(OPEN_SUPPORT_MODAL_EVENT));
}

View file

@ -0,0 +1,180 @@
"use client";
import Link from "next/link";
import { ArrowLeft, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { openContactSupport } from "@/components/support/contact-support-dialog";
interface DocArticleProps {
title: string;
description?: string;
icon?: LucideIcon;
children: React.ReactNode;
}
export function DocArticle({
title,
description,
icon: Icon,
children,
}: DocArticleProps) {
return (
<article className="space-y-6">
<div>
<Link
href="/docs"
className="mb-3 inline-flex items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3" />
All docs
</Link>
<div className="flex items-start gap-3">
{Icon && (
<div className="mt-1 flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Icon className="size-[18px]" />
</div>
)}
<div>
<h1 className="text-xl font-bold tracking-tight text-foreground sm:text-2xl">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">
{description}
</p>
)}
</div>
</div>
</div>
<div className="space-y-6 text-sm leading-relaxed text-foreground">
{children}
</div>
</article>
);
}
interface DocSectionProps {
title: string;
children: React.ReactNode;
}
export function DocSection({ title, children }: DocSectionProps) {
return (
<section className="space-y-3">
<h2 className="border-b border-border/50 pb-2 text-base font-semibold tracking-tight text-foreground">
{title}
</h2>
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground [&_code]:rounded-md [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em] [&_code]:text-foreground [&_strong]:text-foreground [&_a]:text-primary [&_a:hover]:underline">
{children}
</div>
</section>
);
}
interface DocStepsProps {
children: React.ReactNode;
}
export function DocSteps({ children }: DocStepsProps) {
return <ol className="space-y-3 pl-0">{children}</ol>;
}
interface DocStepProps {
title: string;
children: React.ReactNode;
index: number;
}
export function DocStep({ title, children, index }: DocStepProps) {
return (
<li className="flex gap-4">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-bold text-primary">
{index}
</span>
<div className="min-w-0 flex-1 space-y-1 pt-0.5">
<p className="text-sm font-semibold text-foreground">{title}</p>
<div className="text-sm leading-relaxed text-muted-foreground">
{children}
</div>
</div>
</li>
);
}
interface DocCalloutProps {
tone?: "info" | "warning" | "tip";
title?: string;
children: React.ReactNode;
}
export function DocCallout({
tone = "info",
title,
children,
}: DocCalloutProps) {
const tones = {
info: "border-primary/30 bg-primary/5",
warning: "border-amber-500/30 bg-amber-500/5",
tip: "border-emerald-500/30 bg-emerald-500/5",
} as const;
return (
<Card className={tones[tone]}>
<CardContent className="p-4 text-sm">
{title && (
<p className="mb-1 text-sm font-semibold text-foreground">{title}</p>
)}
<div className="text-sm leading-relaxed text-muted-foreground">
{children}
</div>
</CardContent>
</Card>
);
}
interface DocNextProps {
href: string;
title: string;
description?: string;
}
export function DocNext({ href, title, description }: DocNextProps) {
return (
<Card className="border-primary/20 transition-colors hover:border-primary/40">
<CardContent className="p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Next up
</p>
<Link href={href} className="mt-1 block">
<p className="text-sm font-semibold text-foreground hover:text-primary">
{title} &rarr;
</p>
{description && (
<p className="mt-0.5 text-xs text-muted-foreground">{description}</p>
)}
</Link>
</CardContent>
</Card>
);
}
interface DocCtaContactProps {
label?: string;
}
export function DocContactCta({ label }: DocCtaContactProps) {
return (
<Card className="border-primary/20 bg-primary/5">
<CardContent className="flex flex-col items-start gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{label || "Still stuck? Our team is happy to help."}
</p>
<Button size="sm" type="button" onClick={openContactSupport}>
Contact support
</Button>
</CardContent>
</Card>
);
}

View file

@ -203,6 +203,91 @@ export async function sendVerificationEmail(email: string, baseUrl: string) {
await sendEmail(email, "Verify your email — Echo", html);
}
/* ------------------------------------------------------------------ */
/* Support ticket email (ingested by Libredesk) */
/* ------------------------------------------------------------------ */
export type SupportTicketCategory =
| "bug"
| "question"
| "feature"
| "billing"
| "other";
const CATEGORY_LABELS: Record<SupportTicketCategory, string> = {
bug: "Bug report",
question: "Question",
feature: "Feature request",
billing: "Billing",
other: "Other",
};
export interface SupportTicketPayload {
subject: string;
message: string;
category: SupportTicketCategory;
userEmail: string;
userName?: string | null;
userId?: string | null;
orgName?: string | null;
orgId?: string | null;
pageUrl?: string | null;
userAgent?: string | null;
appEnv?: string | null;
}
export async function sendSupportTicketEmail(payload: SupportTicketPayload) {
const supportInbox = process.env.SUPPORT_EMAIL || "support@stillwell.cloud";
const categoryLabel = CATEGORY_LABELS[payload.category] || payload.category;
const submittedAt = new Date().toISOString();
const lines = [
"New support request submitted from the Echo app.",
"",
"---- Request ----",
`Subject: ${payload.subject}`,
`Category: ${categoryLabel} (${payload.category})`,
`Submitted At: ${submittedAt}`,
"",
"---- User ----",
`Name: ${payload.userName || "(not set)"}`,
`Email: ${payload.userEmail}`,
`User ID: ${payload.userId || "(anonymous)"}`,
"",
"---- Organization ----",
`Organization: ${payload.orgName || "(none)"}`,
`Org ID: ${payload.orgId || "(none)"}`,
"",
"---- Context ----",
`App URL: ${payload.pageUrl || SITE_URL}`,
`Site: ${SITE_HOSTNAME}`,
`App Env: ${payload.appEnv || process.env.NEXT_PUBLIC_ENV || "production"}`,
`User Agent: ${payload.userAgent || "(unknown)"}`,
"",
"---- Message ----",
payload.message,
"",
"---- End ----",
"This ticket was generated by the Echo in-app Contact Support form.",
"Reply directly to this email to respond to the user.",
];
const textContent = lines.join("\n");
const subject = `[Echo Support] [${categoryLabel}] ${payload.subject}`;
await brevo.transactionalEmails.sendTransacEmail({
sender: DEFAULT_SENDER,
to: [{ email: supportInbox }],
replyTo: {
email: payload.userEmail,
name: payload.userName || undefined,
},
subject,
textContent,
tags: ["echo-support", `category:${payload.category}`],
});
}
/* ------------------------------------------------------------------ */
/* Invitation email */
/* ------------------------------------------------------------------ */