Fix migration test issues: upload button, forgot password, IMAP, FTP UI, surveys nav, integration tests
- Mount UploadModal globally in AppShell so upload works from any page - Add /forgot-password, /reset-password, /s, /api/survey/submit to public paths in middleware - Fix IMAP test URL (/api/email/test -> /api/email-watch/test) and body shape - Fix IMAP scan URL to use /api/email-watch with action body - Add FTP server settings section to Upload Sources page with test connection - Add "Surveys" nav item in sidebar with dedicated page showing links, QR codes - Include org slug in form-templates API response for survey URL construction - Add pre-creation "Test Connection" button on new integration page - Create /api/integrations/test endpoint for pre-creation connection testing - Replace overflow-hidden with overflow-clip on Card to fix click/z-index issues Made-with: Cursor
This commit is contained in:
parent
70134b4967
commit
3e5449cf4f
9 changed files with 607 additions and 9 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, ArrowLeft, Plug, Plus } from "lucide-react";
|
import { Loader2, ArrowLeft, Plug, Plus, Check, Zap } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -52,6 +52,7 @@ function NewIntegrationForm() {
|
||||||
const [config, setConfig] = React.useState<Record<string, string>>({});
|
const [config, setConfig] = React.useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [creating, setCreating] = React.useState(false);
|
const [creating, setCreating] = React.useState(false);
|
||||||
|
const [testStatus, setTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
fetch("/api/integrations")
|
fetch("/api/integrations")
|
||||||
|
|
@ -74,6 +75,29 @@ function NewIntegrationForm() {
|
||||||
|
|
||||||
const provider = providers.find((p) => p.id === selectedProvider);
|
const provider = providers.find((p) => p.id === selectedProvider);
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!selectedProvider) return;
|
||||||
|
setTestStatus("testing");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/integrations/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ provider: selectedProvider, config }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.ok) {
|
||||||
|
setTestStatus("success");
|
||||||
|
toast.success(data.message || "Connection successful");
|
||||||
|
} else {
|
||||||
|
setTestStatus("error");
|
||||||
|
toast.error(data.error || "Connection failed");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTestStatus("error");
|
||||||
|
toast.error("Connection test failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
const handleCreate = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedProvider) {
|
if (!selectedProvider) {
|
||||||
|
|
@ -234,6 +258,23 @@ function NewIntegrationForm() {
|
||||||
>
|
>
|
||||||
Change Provider
|
Change Provider
|
||||||
</Button>
|
</Button>
|
||||||
|
{!provider?.supportsOAuth && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testStatus === "testing"}
|
||||||
|
>
|
||||||
|
{testStatus === "testing" ? (
|
||||||
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
) : testStatus === "success" ? (
|
||||||
|
<Check className="mr-2 size-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Zap className="mr-2 size-4" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button type="submit" disabled={creating} className="flex-1">
|
<Button type="submit" disabled={creating} className="flex-1">
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
Save,
|
Save,
|
||||||
Mail,
|
Mail,
|
||||||
FolderSearch,
|
FolderSearch,
|
||||||
|
Server,
|
||||||
Wifi,
|
Wifi,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
Check,
|
Check,
|
||||||
|
|
@ -32,7 +33,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
type EmailSettings = {
|
type SourceSettings = {
|
||||||
emailImapHost: string;
|
emailImapHost: string;
|
||||||
emailImapPort: number;
|
emailImapPort: number;
|
||||||
emailImapUser: string;
|
emailImapUser: string;
|
||||||
|
|
@ -42,12 +43,20 @@ type EmailSettings = {
|
||||||
emailWatching: boolean;
|
emailWatching: boolean;
|
||||||
emailProcessed: string;
|
emailProcessed: string;
|
||||||
emailProcessedFolder: string;
|
emailProcessedFolder: string;
|
||||||
|
ftpEnabled: boolean;
|
||||||
|
ftpHost: string;
|
||||||
|
ftpPort: number;
|
||||||
|
ftpUser: string;
|
||||||
|
ftpPass: string;
|
||||||
|
ftpTls: boolean;
|
||||||
|
ftpIncomingDir: string;
|
||||||
|
ftpProcessedDir: string;
|
||||||
watchDir: string;
|
watchDir: string;
|
||||||
watching: boolean;
|
watching: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UploadSourcesPage() {
|
export default function UploadSourcesPage() {
|
||||||
const [settings, setSettings] = React.useState<EmailSettings>({
|
const [settings, setSettings] = React.useState<SourceSettings>({
|
||||||
emailImapHost: "",
|
emailImapHost: "",
|
||||||
emailImapPort: 993,
|
emailImapPort: 993,
|
||||||
emailImapUser: "",
|
emailImapUser: "",
|
||||||
|
|
@ -57,6 +66,14 @@ export default function UploadSourcesPage() {
|
||||||
emailWatching: false,
|
emailWatching: false,
|
||||||
emailProcessed: "mark_read",
|
emailProcessed: "mark_read",
|
||||||
emailProcessedFolder: "Processed",
|
emailProcessedFolder: "Processed",
|
||||||
|
ftpEnabled: false,
|
||||||
|
ftpHost: "",
|
||||||
|
ftpPort: 21,
|
||||||
|
ftpUser: "",
|
||||||
|
ftpPass: "",
|
||||||
|
ftpTls: true,
|
||||||
|
ftpIncomingDir: "/incoming",
|
||||||
|
ftpProcessedDir: "/processed",
|
||||||
watchDir: "",
|
watchDir: "",
|
||||||
watching: false,
|
watching: false,
|
||||||
});
|
});
|
||||||
|
|
@ -65,6 +82,9 @@ export default function UploadSourcesPage() {
|
||||||
const [emailTestStatus, setEmailTestStatus] = React.useState<
|
const [emailTestStatus, setEmailTestStatus] = React.useState<
|
||||||
"idle" | "testing" | "success" | "error"
|
"idle" | "testing" | "success" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
|
const [ftpTestStatus, setFtpTestStatus] = React.useState<
|
||||||
|
"idle" | "testing" | "success" | "error"
|
||||||
|
>("idle");
|
||||||
const [scanning, setScanning] = React.useState(false);
|
const [scanning, setScanning] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -81,6 +101,14 @@ export default function UploadSourcesPage() {
|
||||||
emailWatching: data.emailWatching || false,
|
emailWatching: data.emailWatching || false,
|
||||||
emailProcessed: data.emailProcessed || "mark_read",
|
emailProcessed: data.emailProcessed || "mark_read",
|
||||||
emailProcessedFolder: data.emailProcessedFolder || "Processed",
|
emailProcessedFolder: data.emailProcessedFolder || "Processed",
|
||||||
|
ftpEnabled: data.ftpEnabled || false,
|
||||||
|
ftpHost: data.ftpHost || "",
|
||||||
|
ftpPort: data.ftpPort || 21,
|
||||||
|
ftpUser: data.ftpUser || "",
|
||||||
|
ftpPass: data.ftpPass || "",
|
||||||
|
ftpTls: data.ftpTls ?? true,
|
||||||
|
ftpIncomingDir: data.ftpIncomingDir || "/incoming",
|
||||||
|
ftpProcessedDir: data.ftpProcessedDir || "/processed",
|
||||||
watchDir: data.watchDir || "",
|
watchDir: data.watchDir || "",
|
||||||
watching: data.watching || false,
|
watching: data.watching || false,
|
||||||
});
|
});
|
||||||
|
|
@ -109,14 +137,21 @@ export default function UploadSourcesPage() {
|
||||||
const testEmail = async () => {
|
const testEmail = async () => {
|
||||||
setEmailTestStatus("testing");
|
setEmailTestStatus("testing");
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/email/test", {
|
const res = await fetch("/api/email-watch/test", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(settings),
|
body: JSON.stringify({
|
||||||
|
host: settings.emailImapHost,
|
||||||
|
port: settings.emailImapPort,
|
||||||
|
user: settings.emailImapUser,
|
||||||
|
pass: settings.emailImapPass,
|
||||||
|
tls: settings.emailImapTls,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
const data = await res.json();
|
||||||
setEmailTestStatus(res.ok ? "success" : "error");
|
setEmailTestStatus(res.ok ? "success" : "error");
|
||||||
if (res.ok) toast.success("Email connection successful");
|
if (res.ok) toast.success(data.message || "Email connection successful");
|
||||||
else toast.error("Email connection failed");
|
else toast.error(data.error || "Email connection failed");
|
||||||
} catch {
|
} catch {
|
||||||
setEmailTestStatus("error");
|
setEmailTestStatus("error");
|
||||||
toast.error("Email test failed");
|
toast.error("Email test failed");
|
||||||
|
|
@ -126,10 +161,17 @@ export default function UploadSourcesPage() {
|
||||||
const scanNow = async () => {
|
const scanNow = async () => {
|
||||||
setScanning(true);
|
setScanning(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/email/scan", { method: "POST" });
|
const res = await fetch("/api/email-watch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "scan" }),
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
toast.success(`Scanned ${data.processed || 0} emails`);
|
toast.success(`Scanned ${data.processed || 0} emails`);
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
toast.error(data.error || "Scan failed");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Scan failed");
|
toast.error("Scan failed");
|
||||||
|
|
@ -138,6 +180,34 @@ export default function UploadSourcesPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const testFtp = async () => {
|
||||||
|
setFtpTestStatus("testing");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ftp-watch/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
host: settings.ftpHost,
|
||||||
|
port: settings.ftpPort,
|
||||||
|
user: settings.ftpUser,
|
||||||
|
pass: settings.ftpPass,
|
||||||
|
tls: settings.ftpTls,
|
||||||
|
incomingDir: settings.ftpIncomingDir,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setFtpTestStatus(res.ok && data.ok ? "success" : "error");
|
||||||
|
if (res.ok && data.ok) {
|
||||||
|
toast.success(`FTP connected — ${data.files ?? 0} file(s) found`);
|
||||||
|
} else {
|
||||||
|
toast.error(data.error || "FTP connection failed");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setFtpTestStatus("error");
|
||||||
|
toast.error("FTP test failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleFolderWatch = async () => {
|
const toggleFolderWatch = async () => {
|
||||||
const endpoint = settings.watching ? "/api/watch/stop" : "/api/watch/start";
|
const endpoint = settings.watching ? "/api/watch/stop" : "/api/watch/start";
|
||||||
try {
|
try {
|
||||||
|
|
@ -294,6 +364,134 @@ export default function UploadSourcesPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* FTP */}
|
||||||
|
<Card className="glass-card">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Server className="size-4" />
|
||||||
|
FTP Server
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Poll an FTP server for new scanned files. Files are downloaded,
|
||||||
|
processed, then moved to a “processed” directory.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={settings.ftpEnabled}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSettings((s) => ({ ...s, ftpEnabled: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Enable FTP watcher</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>FTP Host</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.ftpHost}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, ftpHost: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="ftp.example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Port</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={settings.ftpPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
ftpPort: parseInt(e.target.value) || 21,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.ftpUser}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, ftpUser: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={settings.ftpPass}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, ftpPass: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={settings.ftpTls}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setSettings((s) => ({ ...s, ftpTls: !!v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Use TLS</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Incoming Directory</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.ftpIncomingDir}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({ ...s, ftpIncomingDir: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="/incoming"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Processed Directory</Label>
|
||||||
|
<Input
|
||||||
|
value={settings.ftpProcessedDir}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((s) => ({
|
||||||
|
...s,
|
||||||
|
ftpProcessedDir: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="/processed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={testFtp}
|
||||||
|
disabled={!settings.ftpHost || !settings.ftpUser}
|
||||||
|
>
|
||||||
|
{ftpTestStatus === "testing" ? (
|
||||||
|
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||||
|
) : ftpTestStatus === "success" ? (
|
||||||
|
<Check className="mr-1.5 size-3.5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Server className="mr-1.5 size-3.5" />
|
||||||
|
)}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Folder Watch */}
|
{/* Folder Watch */}
|
||||||
<Card className="glass-card">
|
<Card className="glass-card">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
|
||||||
295
src/app/(dashboard)/surveys/page.tsx
Normal file
295
src/app/(dashboard)/surveys/page.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
QrCode,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
ListChecks,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
|
||||||
|
type SurveyTemplate = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
_count?: { fields: number; cards: number };
|
||||||
|
organization: { slug: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SurveysPage() {
|
||||||
|
const [templates, setTemplates] = React.useState<SurveyTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [copiedId, setCopiedId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
fetch("/api/form-templates")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setTemplates(data.templates ?? []))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const baseUrl = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
|
||||||
|
const getSurveyUrl = (t: SurveyTemplate) =>
|
||||||
|
`${baseUrl}/s/${t.organization.slug}/${t.slug}`;
|
||||||
|
|
||||||
|
const handleCopy = async (t: SurveyTemplate) => {
|
||||||
|
const url = getSurveyUrl(t);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} catch {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.value = url;
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(input);
|
||||||
|
}
|
||||||
|
setCopiedId(t.id);
|
||||||
|
toast.success("Survey link copied");
|
||||||
|
setTimeout(() => setCopiedId(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadQR = async (t: SurveyTemplate) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/form-templates/${t.id}/qr`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `qr-${t.slug}.png`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to download QR code");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTemplates = templates.filter((t) => t.isActive);
|
||||||
|
const inactiveTemplates = templates.filter((t) => !t.isActive);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Header
|
||||||
|
title="Surveys"
|
||||||
|
description="Share digital surveys via link or QR code"
|
||||||
|
icon={ClipboardList}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
render={<Link href="/settings/forms" />}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Manage Templates
|
||||||
|
</Button>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="glass-card flex flex-col items-center gap-3 rounded-2xl p-12 text-center">
|
||||||
|
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<ClipboardList className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">No form templates yet</p>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Create a form template to start sharing digital surveys.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
render={<Link href="/settings/forms" />}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Create Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTemplates.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
Active Surveys
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{activeTemplates.map((t) => (
|
||||||
|
<SurveyCard
|
||||||
|
key={t.id}
|
||||||
|
template={t}
|
||||||
|
surveyUrl={getSurveyUrl(t)}
|
||||||
|
copied={copiedId === t.id}
|
||||||
|
onCopy={() => handleCopy(t)}
|
||||||
|
onDownloadQR={() => handleDownloadQR(t)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inactiveTemplates.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
|
Inactive
|
||||||
|
</h3>
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{inactiveTemplates.map((t) => (
|
||||||
|
<SurveyCard
|
||||||
|
key={t.id}
|
||||||
|
template={t}
|
||||||
|
surveyUrl={getSurveyUrl(t)}
|
||||||
|
copied={copiedId === t.id}
|
||||||
|
onCopy={() => handleCopy(t)}
|
||||||
|
onDownloadQR={() => handleDownloadQR(t)}
|
||||||
|
inactive
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SurveyCard({
|
||||||
|
template,
|
||||||
|
surveyUrl,
|
||||||
|
copied,
|
||||||
|
onCopy,
|
||||||
|
onDownloadQR,
|
||||||
|
inactive,
|
||||||
|
}: {
|
||||||
|
template: SurveyTemplate;
|
||||||
|
surveyUrl: string;
|
||||||
|
copied: boolean;
|
||||||
|
onCopy: () => void;
|
||||||
|
onDownloadQR: () => void;
|
||||||
|
inactive?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className={`glass-card ${inactive ? "opacity-60" : ""}`}>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<span className="truncate">{template.name}</span>
|
||||||
|
{template.isDefault && (
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!template.isActive && (
|
||||||
|
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||||
|
Inactive
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
{template.description && (
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground line-clamp-1">
|
||||||
|
{template.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
render={<Link href={`/settings/forms/${template.id}`} />}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ListChecks className="size-3.5" />
|
||||||
|
{template._count?.fields ?? 0} fields
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<ClipboardList className="size-3.5" />
|
||||||
|
{template._count?.cards ?? 0} responses
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="glass-input min-w-0 flex-1 truncate rounded-lg px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
|
{surveyUrl}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="xs" onClick={onCopy}>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-3 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3" />
|
||||||
|
)}
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="xs"
|
||||||
|
render={
|
||||||
|
<a href={surveyUrl} target="_blank" rel="noopener noreferrer" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border bg-white p-1">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={`/api/form-templates/${template.id}/qr`}
|
||||||
|
alt="QR Code"
|
||||||
|
width={80}
|
||||||
|
height={80}
|
||||||
|
className="size-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Scan to open the survey on a mobile device.
|
||||||
|
</p>
|
||||||
|
<Button variant="outline" size="xs" onClick={onDownloadQR}>
|
||||||
|
<Download className="size-3" />
|
||||||
|
Download QR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ export async function GET(request: NextRequest) {
|
||||||
where: { organizationId: session.user.orgId },
|
where: { organizationId: session.user.orgId },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
|
organization: { select: { slug: true } },
|
||||||
_count: { select: { fields: true, cards: true } },
|
_count: { select: { fields: true, cards: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
38
src/app/api/integrations/test/route.ts
Normal file
38
src/app/api/integrations/test/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireApiAuthWithOrg, handleApiError } from "@/lib/api-auth";
|
||||||
|
import { getProvider } from "@/lib/integrations/registry";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
await requireApiAuthWithOrg();
|
||||||
|
const body = await request.json();
|
||||||
|
const { provider: providerId, config } = body as {
|
||||||
|
provider?: string;
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!providerId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Provider is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = getProvider(providerId);
|
||||||
|
if (!provider) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Unknown provider" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await provider.testConnection(config ?? {});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: result.success,
|
||||||
|
message: result.message,
|
||||||
|
error: result.success ? undefined : (result.message || "Connection failed"),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return handleApiError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,27 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { TopBar } from "@/components/layout/top-bar";
|
import { TopBar } from "@/components/layout/top-bar";
|
||||||
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
|
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
|
||||||
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
|
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
|
import { UploadModal } from "@/components/cards/upload-modal";
|
||||||
|
|
||||||
function ShellContent({ children }: { children: React.ReactNode }) {
|
function ShellContent({ children }: { children: React.ReactNode }) {
|
||||||
const { collapsed } = useSidebar();
|
const { collapsed } = useSidebar();
|
||||||
|
const [uploadOpen, setUploadOpen] = React.useState(false);
|
||||||
|
const [uploadFiles, setUploadFiles] = React.useState<File[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
function handleOpenUpload(e: Event) {
|
||||||
|
const detail = (e as CustomEvent).detail;
|
||||||
|
if (detail?.files) setUploadFiles(detail.files);
|
||||||
|
setUploadOpen(true);
|
||||||
|
}
|
||||||
|
window.addEventListener("open-upload-modal", handleOpenUpload);
|
||||||
|
return () => window.removeEventListener("open-upload-modal", handleOpenUpload);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen">
|
<div className="relative min-h-screen">
|
||||||
|
|
@ -21,6 +35,11 @@ function ShellContent({ children }: { children: React.ReactNode }) {
|
||||||
<TopBar />
|
<TopBar />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
|
<UploadModal
|
||||||
|
open={uploadOpen}
|
||||||
|
onOpenChange={setUploadOpen}
|
||||||
|
initialFiles={uploadFiles.length > 0 ? uploadFiles : undefined}
|
||||||
|
/>
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Users,
|
Users,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
ClipboardList,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronsLeft,
|
ChevronsLeft,
|
||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
|
|
@ -29,6 +30,7 @@ const navItems = [
|
||||||
{ href: "/cards", label: "Response Cards", icon: CreditCard },
|
{ href: "/cards", label: "Response Cards", icon: CreditCard },
|
||||||
{ href: "/events", label: "Collection Days", icon: CalendarDays },
|
{ href: "/events", label: "Collection Days", icon: CalendarDays },
|
||||||
{ href: "/people", label: "People", icon: Users },
|
{ href: "/people", label: "People", icon: Users },
|
||||||
|
{ href: "/surveys", label: "Surveys", icon: ClipboardList },
|
||||||
{ href: "/reports", label: "Reports", icon: BarChart3 },
|
{ href: "/reports", label: "Reports", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ function Card({
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl py-4 text-sm text-card-foreground transition-shadow has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
"group/card flex flex-col gap-4 overflow-clip rounded-xl py-4 text-sm text-card-foreground transition-shadow has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
variant === "glass"
|
variant === "glass"
|
||||||
? "glass-card"
|
? "glass-card"
|
||||||
: "bg-card ring-1 ring-foreground/10 glass",
|
: "bg-card ring-1 ring-foreground/10 glass",
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,16 @@ const publicPaths = [
|
||||||
"/login",
|
"/login",
|
||||||
"/signup",
|
"/signup",
|
||||||
"/invite",
|
"/invite",
|
||||||
|
"/forgot-password",
|
||||||
|
"/reset-password",
|
||||||
"/setup",
|
"/setup",
|
||||||
|
"/s",
|
||||||
"/api/auth",
|
"/api/auth",
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/setup",
|
"/api/setup",
|
||||||
"/api/onboarding",
|
"/api/onboarding",
|
||||||
"/api/invitations/verify",
|
"/api/invitations/verify",
|
||||||
|
"/api/survey/submit",
|
||||||
"/api/jobs/process",
|
"/api/jobs/process",
|
||||||
"/api/email-watch/poll",
|
"/api/email-watch/poll",
|
||||||
"/api/ftp-watch/poll",
|
"/api/ftp-watch/poll",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue