diff --git a/.env.example b/.env.example index 1ec8745..ec6ec3a 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/src/app/(dashboard)/docs/faq/page.tsx b/src/app/(dashboard)/docs/faq/page.tsx new file mode 100644 index 0000000..3532dca --- /dev/null +++ b/src/app/(dashboard)/docs/faq/page.tsx @@ -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 ( + + +

+ 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{" "} + Needs Review state so a human can correct anything + the AI wasn’t sure about before it hits your CRM. +

+
+ + +

+ 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{" "} + Privacy Policy for full details. +

+
+ + +

+ By default, cards are retained for 2 years. Administrators can change + the retention window under{" "} + Settings → Organization, or delete individual + cards at any time. +

+
+ + +

+ 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. +

+
+ + +

+ Echo is priced per card processed, with a generous monthly free tier + for small churches. See the pricing page for + current tiers. Billing questions? Use the Contact button below — + it opens a billing-tagged ticket. +

+
+ + +

+ 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. +

+
+ + +

+ 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{" "} + Troubleshooting guide has more + detail. +

+
+ + +

+ Go to Settings → Organization → Billing and + click Cancel plan. Your data stays accessible until + the end of the billing period. +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/docs/forms-templates/page.tsx b/src/app/(dashboard)/docs/forms-templates/page.tsx new file mode 100644 index 0000000..fa7c160 --- /dev/null +++ b/src/app/(dashboard)/docs/forms-templates/page.tsx @@ -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 ( + + +

+ A form template 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., Name, Email, First-time visitor, + etc.) and where to put the values. +

+

+ You can have as many templates as you want — one per card type, + event, or campaign. +

+
+ + + + + Go to Settings → Form Templates and click{" "} + New template. + + + Upload a clean, filled-in sample. Echo uses it for the preview and + to test extraction later. + + + For each piece of data on the card, add a field with: +
    +
  • + Label — human-readable name (e.g.,{" "} + “Preferred Name”). +
  • +
  • + Type — text, email, phone, checkbox, + choice, or multi-line. +
  • +
  • + Mapping — which person attribute this + field writes to (e.g.,{" "} + person.email, person.visitType). +
  • +
  • + Required? — required fields block{" "} + Confirm until filled. +
  • +
+
+ + Hit Test. 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. + + + Mark the template as Active and set it as the + default (or assign per upload source). + +
+
+ + + + 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. + + + For rows of checkboxes (e.g., “First time / Returning / + Member”), use the Choice field type and list + the options exactly as they appear on the card. + + + + +

+ 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{" "} + not auto-detect which template fits a given card. +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/docs/getting-started/page.tsx b/src/app/(dashboard)/docs/getting-started/page.tsx new file mode 100644 index 0000000..4a9e2a5 --- /dev/null +++ b/src/app/(dashboard)/docs/getting-started/page.tsx @@ -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 ( + + + + + Head to /signup and create an account with your work + email. If your church already uses Stillwell SSO, click{" "} + Sign in with SSO on the login page. + + + Check your inbox for a verification email from{" "} + mars@noreply.stillwell.cloud and click the link. You + must verify before uploading cards. + + + After verifying, you’ll be guided through onboarding. Give your + organization a name (usually your church or ministry name) and + confirm. + + + + + +

+ Go to Settings → Team Members and click{" "} + Invite. Choose a role for each teammate: +

+ +

+ Invites expire after 7 days. You can revoke an unused invitation from + the same page. +

+
+ + +

+ Echo uses form templates to know what fields to + extract from each card. Head to{" "} + Settings → Form Templates and either start from + a starter template or build your own. +

+ + The default “Response Card” template handles name, email, + phone, and attendance — a great starting point for most + churches. + +
+ + + + + Use the Upload button in the top bar, or press{" "} + ⌘K and choose Upload Documents. + + + Drag & drop PDFs, JPGs, or PNGs. You can upload up to 50 files + at a time. + + + Echo processes each card in the background. Head to{" "} + Response Cards to review extracted fields, correct + anything, and assign the card to a person. + + + + + +
+ ); +} diff --git a/src/app/(dashboard)/docs/layout.tsx b/src/app/(dashboard)/docs/layout.tsx new file mode 100644 index 0000000..0a3812d --- /dev/null +++ b/src/app/(dashboard)/docs/layout.tsx @@ -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 ( +
+ + +
{children}
+
+ ); +} diff --git a/src/app/(dashboard)/docs/page.tsx b/src/app/(dashboard)/docs/page.tsx new file mode 100644 index 0000000..ef21841 --- /dev/null +++ b/src/app/(dashboard)/docs/page.tsx @@ -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 ( +
+
+ + + +
+ + setQuery(e.target.value)} + placeholder="Search the docs…" + className="pl-9" + aria-label="Search the documentation" + /> +
+

+ Try searching for “upload”, “Planning Center”, + or “pricing”. +

+
+
+ + {filtered.length === 0 ? ( + + +

+ No matching topics found for “{query}”. +

+ +
+
+ ) : ( +
+ {filtered.map((topic) => { + const Icon = topic.icon; + return ( + + + +
+
+ +
+

+ {topic.title} +

+ +
+

+ {topic.description} +

+
+
+ + ); + })} +
+ )} + + + +
+
+ +
+
+

Still have questions?

+

+ Send us a message and we’ll get back to you shortly. +

+
+
+ +
+
+
+ ); +} diff --git a/src/app/(dashboard)/docs/reports-exports/page.tsx b/src/app/(dashboard)/docs/reports-exports/page.tsx new file mode 100644 index 0000000..ed29271 --- /dev/null +++ b/src/app/(dashboard)/docs/reports-exports/page.tsx @@ -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 ( + + +

+ The Reports page gives you a running view of: +

+ +

+ Use the filter bar at the top to scope any report by date range, + collection day, location, or assignee. +

+
+ + + + + On the Response Cards page, filter to the exact + set you want to export. + + + Use the Export button in the toolbar. The CSV includes every mapped + field plus metadata (upload date, reviewer, collection day). + + + Most ChMS and CRM tools accept CSV uploads. Check their docs for + column-name requirements. + + + + Need a recurring export? Set up the{" "} + CSV Export integration to drop a CSV to FTP, email, + or a webhook on a schedule. + + + + +

+ When a card is Confirmed, Echo can push it to any + connected integration. Configure the trigger per integration (every + confirm vs. batched daily) under{" "} + Settings → Integrations. +

+

+ For a full walkthrough of each provider, see the{" "} + Integrations docs. +

+
+ + +
+ ); +} diff --git a/src/app/(dashboard)/docs/troubleshooting/page.tsx b/src/app/(dashboard)/docs/troubleshooting/page.tsx new file mode 100644 index 0000000..031e2e1 --- /dev/null +++ b/src/app/(dashboard)/docs/troubleshooting/page.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + +

+ 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: +

+ +
+ + + + + + + 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. + + + +
+ ); +} diff --git a/src/app/(dashboard)/docs/uploading-cards/page.tsx b/src/app/(dashboard)/docs/uploading-cards/page.tsx new file mode 100644 index 0000000..1bbf6b1 --- /dev/null +++ b/src/app/(dashboard)/docs/uploading-cards/page.tsx @@ -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 ( + + + + + + + + + Click the Upload button in the top bar, or press{" "} + ⌘KUpload Documents. + + + Tagging the upload with a collection day (e.g., “Easter + Sunday”) makes later reporting easier. + + + Drop files anywhere on the dialog. Echo uploads them to storage and + queues each for OCR immediately. + + + + + +

+ Echo can monitor an email inbox and automatically ingest any cards + sent as attachments. Configure it under{" "} + Settings → Upload Sources → Email: +

+ + + Ideally a Gmail or IMAP-capable inbox only used for Echo (e.g.,{" "} + cards@yourchurch.org). + + + Echo stores the credentials encrypted and uses a long-lived IMAP + IDLE connection to pick up new mail within seconds. + + + Forward a card to the watched address. It should appear in{" "} + Response Cards within a minute. + + + + For Gmail, create an App Password — your main account password + will not work. + +
+ + +

+ If your scanner drops files to an FTP/SFTP location, Echo can poll it + and pull new files in automatically. Configure under{" "} + Settings → Upload Sources → FTP. Both FTP + and SFTP are supported. +

+ + 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. + +
+ + +

Every uploaded file moves through these states:

+ +
+ + +
+ ); +} diff --git a/src/app/(marketing)/features/integrations/page.tsx b/src/app/(marketing)/features/integrations/page.tsx index 47842ac..8183ebc 100644 --- a/src/app/(marketing)/features/integrations/page.tsx +++ b/src/app/(marketing)/features/integrations/page.tsx @@ -156,7 +156,7 @@ export default function IntegrationsPage() { on our Enterprise plan.

Request an integration diff --git a/src/app/(marketing)/pricing/page.tsx b/src/app/(marketing)/pricing/page.tsx index 843cafb..a9a5133 100644 --- a/src/app/(marketing)/pricing/page.tsx +++ b/src/app/(marketing)/pricing/page.tsx @@ -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, }, ]; diff --git a/src/app/(marketing)/privacy/page.tsx b/src/app/(marketing)/privacy/page.tsx index 49e3d48..6987331 100644 --- a/src/app/(marketing)/privacy/page.tsx +++ b/src/app/(marketing)/privacy/page.tsx @@ -390,8 +390,8 @@ export default function PrivacyPolicyPage() {

To request deletion of your account and all associated data, please contact us at{" "} - - privacy@echoocr.com + + support@stillwell.cloud . We will process deletion requests within 30 days.

@@ -435,8 +435,8 @@ export default function PrivacyPolicyPage() {

To exercise your rights regarding your Echo account, contact us at{" "} - - privacy@echoocr.com + + support@stillwell.cloud .

@@ -454,8 +454,8 @@ export default function PrivacyPolicyPage() {

If you believe we have inadvertently collected information from a child under 13 without appropriate consent, please contact us immediately at{" "} - - privacy@echoocr.com + + support@stillwell.cloud .

@@ -490,14 +490,14 @@ export default function PrivacyPolicyPage() { diff --git a/src/app/(marketing)/terms/page.tsx b/src/app/(marketing)/terms/page.tsx index ef0481f..5c2cc51 100644 --- a/src/app/(marketing)/terms/page.tsx +++ b/src/app/(marketing)/terms/page.tsx @@ -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{" "} - support@echoocr.com + support@stillwell.cloud .

@@ -423,21 +423,21 @@ export default function TermsOfServicePage() {

diff --git a/src/app/(marketing)/welcome/page.tsx b/src/app/(marketing)/welcome/page.tsx index a0b098d..896eeb8 100644 --- a/src/app/(marketing)/welcome/page.tsx +++ b/src/app/(marketing)/welcome/page.tsx @@ -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, }, ]; diff --git a/src/app/api/support/route.ts b/src/app/api/support/route.ts new file mode 100644 index 0000000..c404378 --- /dev/null +++ b/src/app/api/support/route.ts @@ -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(); + +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 } + ); + } +} diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index 7957e33..1fd2db4 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -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] diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 9f213d0..2dad4e2 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -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} /> +
)} - window.open("mailto:support@echoocr.app", "_blank")} - > + }> + + Help Center + + - Support + Contact Support diff --git a/src/components/marketing/footer.tsx b/src/components/marketing/footer.tsx index fb80a47..ddd52b5 100644 --- a/src/components/marketing/footer.tsx +++ b/src/components/marketing/footer.tsx @@ -31,8 +31,8 @@ export function MarketingFooter() {

Company

diff --git a/src/components/support/contact-support-dialog.tsx b/src/components/support/contact-support-dialog.tsx new file mode 100644 index 0000000..8d8d579 --- /dev/null +++ b/src/components/support/contact-support-dialog.tsx @@ -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("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 ( + + + +
+
+ +
+
+ Contact support + + Our team will reply directly to{" "} + + {profile.email || "your account email"} + + . + +
+
+
+ +
+
+ + +
+ +
+ + setSubject(e.target.value)} + placeholder={ + selectedCategory?.value === "bug" + ? "Short description of the bug" + : "One-line summary" + } + maxLength={SUBJECT_MAX} + required + autoComplete="off" + /> +
+ +
+
+ + + {message.length}/{MESSAGE_MAX} + +
+