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:
parent
be7e3dc502
commit
3a75bfa7de
23 changed files with 1699 additions and 26 deletions
|
|
@ -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=""
|
||||
|
|
|
|||
96
src/app/(dashboard)/docs/faq/page.tsx
Normal file
96
src/app/(dashboard)/docs/faq/page.tsx
Normal 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 — expect 85–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’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 — 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 → 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 — 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 —
|
||||
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’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’t
|
||||
look like a form at all (e.g., a blank envelope). The original file
|
||||
is kept — 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 → Organization → 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>
|
||||
);
|
||||
}
|
||||
109
src/app/(dashboard)/docs/forms-templates/page.tsx
Normal file
109
src/app/(dashboard)/docs/forms-templates/page.tsx
Normal 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 & 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 — 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 → 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> — human-readable name (e.g.,{" "}
|
||||
“Preferred Name”).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Type</strong> — text, email, phone, checkbox,
|
||||
choice, or multi-line.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Mapping</strong> — which person attribute this
|
||||
field writes to (e.g.,{" "}
|
||||
<code>person.email</code>, <code>person.visitType</code>).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Required?</strong> — 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 “Best
|
||||
way to reach you?”, name that field the same way — it
|
||||
improves accuracy dramatically.
|
||||
</DocCallout>
|
||||
<DocCallout tone="tip" title="Use choice fields for checkboxes">
|
||||
For rows of checkboxes (e.g., “First time / Returning /
|
||||
Member”), 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>
|
||||
);
|
||||
}
|
||||
110
src/app/(dashboard)/docs/getting-started/page.tsx
Normal file
110
src/app/(dashboard)/docs/getting-started/page.tsx
Normal 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’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 → 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> — full access including billing and
|
||||
settings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Editor</strong> — can upload, review, and assign
|
||||
cards.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Reviewer</strong> — can review and confirm OCR
|
||||
results, but not change settings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Viewer</strong> — 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 → Form Templates</strong> and either start from
|
||||
a starter template or build your own.
|
||||
</p>
|
||||
<DocCallout tone="tip" title="Tip">
|
||||
The default “Response Card” template handles name, email,
|
||||
phone, and attendance — 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>⌘K</code> and choose <strong>Upload Documents</strong>.
|
||||
</DocStep>
|
||||
<DocStep index={2} title="Pick files">
|
||||
Drag & 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>
|
||||
);
|
||||
}
|
||||
85
src/app/(dashboard)/docs/layout.tsx
Normal file
85
src/app/(dashboard)/docs/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/app/(dashboard)/docs/page.tsx
Normal file
261
src/app/(dashboard)/docs/page.tsx
Normal 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 “upload”, “Planning Center”,
|
||||
or “pricing”.
|
||||
</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 “{query}”.
|
||||
</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’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>
|
||||
);
|
||||
}
|
||||
80
src/app/(dashboard)/docs/reports-exports/page.tsx
Normal file
80
src/app/(dashboard)/docs/reports-exports/page.tsx
Normal 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 & 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 — 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 → 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>
|
||||
);
|
||||
}
|
||||
153
src/app/(dashboard)/docs/troubleshooting/page.tsx
Normal file
153
src/app/(dashboard)/docs/troubleshooting/page.tsx
Normal 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&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 → Upload Sources
|
||||
→ Email</strong> is <em>Connected</em>. If it shows{" "}
|
||||
<em>Error</em>, the credentials are likely stale.
|
||||
</li>
|
||||
<li>
|
||||
For Gmail, make sure you’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’s
|
||||
settings.
|
||||
</li>
|
||||
<li>
|
||||
Verify the sender isn’t being filtered into Spam — 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’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 → 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 → Integrations → Webhook → 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> →{" "}
|
||||
<strong>Retry processing</strong>.
|
||||
</li>
|
||||
<li>
|
||||
If the retry also gets stuck, the file may be corrupted —
|
||||
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 — the
|
||||
email/password flow won’t work for SSO-only accounts.
|
||||
</li>
|
||||
<li>
|
||||
If your email isn’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>
|
||||
);
|
||||
}
|
||||
129
src/app/(dashboard)/docs/uploading-cards/page.tsx
Normal file
129
src/app/(dashboard)/docs/uploading-cards/page.tsx
Normal 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> — single-page or multi-page. Multi-page
|
||||
PDFs are split into one card per page.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Images</strong> — 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>⌘K</code> → <strong>Upload Documents</strong>.
|
||||
</DocStep>
|
||||
<DocStep index={2} title="Pick a collection day (optional)">
|
||||
Tagging the upload with a collection day (e.g., “Easter
|
||||
Sunday”) makes later reporting easier.
|
||||
</DocStep>
|
||||
<DocStep index={3} title="Drag & 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 → Upload Sources → 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 — 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 → Upload Sources → 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 — 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> — file is stored, waiting for a
|
||||
worker.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Processing</strong> — AI is extracting fields against
|
||||
your active form template.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Needs Review</strong> — extraction succeeded; a human
|
||||
should confirm unclear fields.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Confirmed</strong> — reviewer has signed off; card is
|
||||
ready for export/integration push.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Failed</strong> — extraction couldn’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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
106
src/app/api/support/route.ts
Normal file
106
src/app/api/support/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
259
src/components/support/contact-support-dialog.tsx
Normal file
259
src/components/support/contact-support-dialog.tsx
Normal 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));
|
||||
}
|
||||
180
src/components/support/doc-article.tsx
Normal file
180
src/components/support/doc-article.tsx
Normal 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} →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
|
|||
Loading…
Reference in a new issue