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 { useRouter, useSearchParams } from "next/navigation";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -52,6 +52,7 @@ function NewIntegrationForm() {
|
|||
const [config, setConfig] = React.useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [testStatus, setTestStatus] = React.useState<"idle" | "testing" | "success" | "error">("idle");
|
||||
|
||||
React.useEffect(() => {
|
||||
fetch("/api/integrations")
|
||||
|
|
@ -74,6 +75,29 @@ function NewIntegrationForm() {
|
|||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!selectedProvider) {
|
||||
|
|
@ -234,6 +258,23 @@ function NewIntegrationForm() {
|
|||
>
|
||||
Change Provider
|
||||
</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">
|
||||
{creating ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Save,
|
||||
Mail,
|
||||
FolderSearch,
|
||||
Server,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Check,
|
||||
|
|
@ -32,7 +33,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
type EmailSettings = {
|
||||
type SourceSettings = {
|
||||
emailImapHost: string;
|
||||
emailImapPort: number;
|
||||
emailImapUser: string;
|
||||
|
|
@ -42,12 +43,20 @@ type EmailSettings = {
|
|||
emailWatching: boolean;
|
||||
emailProcessed: string;
|
||||
emailProcessedFolder: string;
|
||||
ftpEnabled: boolean;
|
||||
ftpHost: string;
|
||||
ftpPort: number;
|
||||
ftpUser: string;
|
||||
ftpPass: string;
|
||||
ftpTls: boolean;
|
||||
ftpIncomingDir: string;
|
||||
ftpProcessedDir: string;
|
||||
watchDir: string;
|
||||
watching: boolean;
|
||||
};
|
||||
|
||||
export default function UploadSourcesPage() {
|
||||
const [settings, setSettings] = React.useState<EmailSettings>({
|
||||
const [settings, setSettings] = React.useState<SourceSettings>({
|
||||
emailImapHost: "",
|
||||
emailImapPort: 993,
|
||||
emailImapUser: "",
|
||||
|
|
@ -57,6 +66,14 @@ export default function UploadSourcesPage() {
|
|||
emailWatching: false,
|
||||
emailProcessed: "mark_read",
|
||||
emailProcessedFolder: "Processed",
|
||||
ftpEnabled: false,
|
||||
ftpHost: "",
|
||||
ftpPort: 21,
|
||||
ftpUser: "",
|
||||
ftpPass: "",
|
||||
ftpTls: true,
|
||||
ftpIncomingDir: "/incoming",
|
||||
ftpProcessedDir: "/processed",
|
||||
watchDir: "",
|
||||
watching: false,
|
||||
});
|
||||
|
|
@ -65,6 +82,9 @@ export default function UploadSourcesPage() {
|
|||
const [emailTestStatus, setEmailTestStatus] = React.useState<
|
||||
"idle" | "testing" | "success" | "error"
|
||||
>("idle");
|
||||
const [ftpTestStatus, setFtpTestStatus] = React.useState<
|
||||
"idle" | "testing" | "success" | "error"
|
||||
>("idle");
|
||||
const [scanning, setScanning] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
|
@ -81,6 +101,14 @@ export default function UploadSourcesPage() {
|
|||
emailWatching: data.emailWatching || false,
|
||||
emailProcessed: data.emailProcessed || "mark_read",
|
||||
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 || "",
|
||||
watching: data.watching || false,
|
||||
});
|
||||
|
|
@ -109,14 +137,21 @@ export default function UploadSourcesPage() {
|
|||
const testEmail = async () => {
|
||||
setEmailTestStatus("testing");
|
||||
try {
|
||||
const res = await fetch("/api/email/test", {
|
||||
const res = await fetch("/api/email-watch/test", {
|
||||
method: "POST",
|
||||
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");
|
||||
if (res.ok) toast.success("Email connection successful");
|
||||
else toast.error("Email connection failed");
|
||||
if (res.ok) toast.success(data.message || "Email connection successful");
|
||||
else toast.error(data.error || "Email connection failed");
|
||||
} catch {
|
||||
setEmailTestStatus("error");
|
||||
toast.error("Email test failed");
|
||||
|
|
@ -126,10 +161,17 @@ export default function UploadSourcesPage() {
|
|||
const scanNow = async () => {
|
||||
setScanning(true);
|
||||
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) {
|
||||
const data = await res.json();
|
||||
toast.success(`Scanned ${data.processed || 0} emails`);
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data.error || "Scan failed");
|
||||
}
|
||||
} catch {
|
||||
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 endpoint = settings.watching ? "/api/watch/stop" : "/api/watch/start";
|
||||
try {
|
||||
|
|
@ -294,6 +364,134 @@ export default function UploadSourcesPage() {
|
|||
</CardContent>
|
||||
</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 */}
|
||||
<Card className="glass-card">
|
||||
<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 },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
organization: { select: { slug: 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";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TopBar } from "@/components/layout/top-bar";
|
||||
import { Sidebar, SidebarProvider, useSidebar } from "@/components/layout/sidebar";
|
||||
import { EmailVerificationBanner } from "@/components/layout/email-verification-banner";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { UploadModal } from "@/components/cards/upload-modal";
|
||||
|
||||
function ShellContent({ children }: { children: React.ReactNode }) {
|
||||
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 (
|
||||
<div className="relative min-h-screen">
|
||||
|
|
@ -21,6 +35,11 @@ function ShellContent({ children }: { children: React.ReactNode }) {
|
|||
<TopBar />
|
||||
<Sidebar />
|
||||
<CommandPalette />
|
||||
<UploadModal
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
initialFiles={uploadFiles.length > 0 ? uploadFiles : undefined}
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
CalendarDays,
|
||||
Users,
|
||||
BarChart3,
|
||||
ClipboardList,
|
||||
Settings,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
|
|
@ -29,6 +30,7 @@ const navItems = [
|
|||
{ href: "/cards", label: "Response Cards", icon: CreditCard },
|
||||
{ href: "/events", label: "Collection Days", icon: CalendarDays },
|
||||
{ href: "/people", label: "People", icon: Users },
|
||||
{ href: "/surveys", label: "Surveys", icon: ClipboardList },
|
||||
{ href: "/reports", label: "Reports", icon: BarChart3 },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function Card({
|
|||
data-slot="card"
|
||||
data-size={size}
|
||||
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"
|
||||
? "glass-card"
|
||||
: "bg-card ring-1 ring-foreground/10 glass",
|
||||
|
|
|
|||
|
|
@ -10,12 +10,16 @@ const publicPaths = [
|
|||
"/login",
|
||||
"/signup",
|
||||
"/invite",
|
||||
"/forgot-password",
|
||||
"/reset-password",
|
||||
"/setup",
|
||||
"/s",
|
||||
"/api/auth",
|
||||
"/api/health",
|
||||
"/api/setup",
|
||||
"/api/onboarding",
|
||||
"/api/invitations/verify",
|
||||
"/api/survey/submit",
|
||||
"/api/jobs/process",
|
||||
"/api/email-watch/poll",
|
||||
"/api/ftp-watch/poll",
|
||||
|
|
|
|||
Loading…
Reference in a new issue