echos-ocr/src/app/(dashboard)/settings/general/page.tsx
Randall Stillwell a975043670 Add onboarding wizard, email verification, integration architecture, and settings restructure
- Auto-sign-in after registration instead of redirect to login
- Email verification system with token generation, send/confirm API routes, and persistent banner
- 7-step onboarding wizard (org, location, services, upload source, AI, integrations, complete)
- Middleware redirects owners with incomplete onboarding to /onboarding
- Integration provider plugin architecture with registry and 6 providers (Planning Center, Monday.com, Airtable, Google Sheets, Webhook, CSV Export)
- Full integration CRUD API with test, sync, fields, and OAuth authorize/callback routes
- Refactored fireIntegrationEvent to use Integration model with legacy AppSettings fallback
- Migration script for existing Monday.com/webhook config to Integration rows
- Settings page restructured from monolithic 1290-line file into focused sub-routes with section navigation
- Integration hub UI with provider tiles, connect flow, and individual config pages
- Post-onboarding contextual guidance cards on dashboard with dismissible hints
- Schema: Integration model, onboardingComplete/onboardingStep on Organization, dismissedHints on OrgMember

Made-with: Cursor
2026-04-15 01:29:13 -05:00

280 lines
8.2 KiB
TypeScript

"use client";
import * as React from "react";
import { toast } from "sonner";
import { useTheme } from "next-themes";
import {
Loader2,
Save,
Sun,
Moon,
Monitor,
Bell,
HardDrive,
Trash2,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
const NOTIFICATION_STORAGE_KEY = "echo-ocr-notification-prefs";
type NotificationPrefs = {
processingComplete: boolean;
processingErrors: boolean;
folderWatchAlerts: boolean;
cleanupReminders: boolean;
};
const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
processingComplete: true,
processingErrors: true,
folderWatchAlerts: true,
cleanupReminders: false,
};
function loadNotificationPrefs(): NotificationPrefs {
if (typeof window === "undefined") return DEFAULT_NOTIFICATION_PREFS;
try {
const raw = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
if (raw) return { ...DEFAULT_NOTIFICATION_PREFS, ...JSON.parse(raw) };
} catch {}
return DEFAULT_NOTIFICATION_PREFS;
}
export default function GeneralSettingsPage() {
const { theme, setTheme } = useTheme();
const [sourceRetention, setSourceRetention] = React.useState(30);
const [imageRetention, setImageRetention] = React.useState(180);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [cleanupStatus, setCleanupStatus] = React.useState<{
sourcesEligible: number;
imagesEligible: number;
} | null>(null);
const [cleaning, setCleaning] = React.useState(false);
const [notifPrefs, setNotifPrefs] = React.useState<NotificationPrefs>(
DEFAULT_NOTIFICATION_PREFS
);
React.useEffect(() => {
setNotifPrefs(loadNotificationPrefs());
}, []);
React.useEffect(() => {
fetch("/api/settings")
.then((r) => r.json())
.then((data) => {
setSourceRetention(data.sourceRetentionDays || 30);
setImageRetention(data.imageRetentionDays || 180);
setLoading(false);
})
.catch(() => setLoading(false));
fetch("/api/cleanup")
.then((r) => r.json())
.then((data) => {
if (data.sourcesEligible !== undefined) setCleanupStatus(data);
})
.catch(() => {});
}, []);
const updateNotifPref = (key: keyof NotificationPrefs, value: boolean) => {
setNotifPrefs((prev) => {
const next = { ...prev, [key]: value };
try {
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(next));
} catch {}
return next;
});
toast.success("Notification preference updated");
};
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceRetentionDays: sourceRetention,
imageRetentionDays: imageRetention,
}),
});
if (!res.ok) throw new Error();
toast.success("Settings saved");
} catch {
toast.error("Failed to save settings");
} finally {
setSaving(false);
}
};
const runCleanup = async () => {
setCleaning(true);
try {
const res = await fetch("/api/cleanup", { method: "POST" });
if (res.ok) {
const data = await res.json();
toast.success(
`Cleaned ${data.sourcesDeleted ?? 0} sources and ${data.imagesDeleted ?? 0} images`
);
setCleanupStatus({ sourcesEligible: 0, imagesEligible: 0 });
}
} catch {
toast.error("Cleanup failed");
} finally {
setCleaning(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* Appearance */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sun className="size-4" />
Appearance
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
{[
{ value: "light", icon: Sun, label: "Light" },
{ value: "dark", icon: Moon, label: "Dark" },
{ value: "system", icon: Monitor, label: "System" },
].map((opt) => (
<Button
key={opt.value}
variant={theme === opt.value ? "default" : "outline"}
size="sm"
onClick={() => setTheme(opt.value)}
className="gap-1.5"
>
<opt.icon className="size-3.5" />
{opt.label}
</Button>
))}
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bell className="size-4" />
Notifications
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(
[
["processingComplete", "Processing Complete"],
["processingErrors", "Processing Errors"],
["folderWatchAlerts", "Folder Watch Alerts"],
["cleanupReminders", "Cleanup Reminders"],
] as const
).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label className="text-sm">{label}</Label>
<Switch
checked={notifPrefs[key]}
onCheckedChange={(v) => updateNotifPref(key, v)}
/>
</div>
))}
</CardContent>
</Card>
{/* Storage & Retention */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<HardDrive className="size-4" />
Storage & Retention
</CardTitle>
<CardDescription>
Automatically clean up old files to save storage.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Source file retention (days)</Label>
<Input
type="number"
min={1}
value={sourceRetention}
onChange={(e) =>
setSourceRetention(parseInt(e.target.value) || 30)
}
/>
</div>
<div className="space-y-2">
<Label>Image retention (days)</Label>
<Input
type="number"
min={1}
value={imageRetention}
onChange={(e) =>
setImageRetention(parseInt(e.target.value) || 180)
}
/>
</div>
</div>
{cleanupStatus && (
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{cleanupStatus.sourcesEligible} sources,{" "}
{cleanupStatus.imagesEligible} images eligible for cleanup
</span>
<Button
variant="outline"
size="sm"
onClick={runCleanup}
disabled={cleaning}
>
{cleaning ? (
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<Trash2 className="mr-1.5 size-3.5" />
)}
Run Cleanup
</Button>
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
{saving ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Save className="mr-2 size-4" />
)}
Save Changes
</Button>
</div>
</div>
);
}