Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49 files in src/components + src/app. The em-dash reads as a tell-tale "AI-generated" marker per the user's design feedback; hyphens with spaces preserve the connector semantics without the AI tint. Touched only lines outside pure-comment context (// /* * */). Code comments, JSDoc, audit-log strings, structured logging strings, and templates outside the lint scope retain their em-dashes for now — they're not user-visible. Also captured two remaining cases that used the `—` HTML entity instead of the literal character (system-monitoring-dashboard, interest-stage-picker) — replaced with a plain hyphen. Bumped the existing `no-restricted-syntax` rule from `warn` → `error` in eslint.config.mjs scoped to src/components/**/*.tsx + src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now fails the lint gate. Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { CheckCircle2, HelpCircle, Loader2, XCircle } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet';
|
|
import {
|
|
SettingsFormCard,
|
|
type SettingFieldDef,
|
|
} from '@/components/admin/shared/settings-form-card';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { toastError } from '@/lib/api/toast-error';
|
|
|
|
interface TestResult {
|
|
ok: boolean;
|
|
host?: string;
|
|
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
|
|
error?: string;
|
|
at: Date;
|
|
}
|
|
|
|
const EMBED_FIELDS: SettingFieldDef[] = [
|
|
{
|
|
key: 'embedded_signing_host',
|
|
label: 'Embedded signing host',
|
|
description:
|
|
"Origin of the public site that hosts the embedded Documenso signing pages. Outbound emails wrap raw Documenso signing URLs into {host}/sign/<type>/<token> so clients sign on your branded page rather than Documenso's domain. Leave blank to fall back to the app URL. Marketing-website pattern: https://portnimara.com",
|
|
type: 'string',
|
|
placeholder: 'https://portnimara.com',
|
|
defaultValue: '',
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Admin card for the embedded-signing host setting. Provides:
|
|
* - The setting field itself (via SettingsFormCard)
|
|
* - A Test connection button that probes the host's `/` and
|
|
* `/sign/success` paths to verify the marketing-site cutover is
|
|
* ready BEFORE signers get sent there from outbound emails.
|
|
* - A Help button that opens a Sheet with the setup instructions —
|
|
* what routes the marketing site needs, what URL parameters to
|
|
* handle, and the Documenso webhook config that pairs with it.
|
|
*/
|
|
export function EmbeddedSigningCard() {
|
|
const [testing, setTesting] = useState(false);
|
|
const [result, setResult] = useState<TestResult | null>(null);
|
|
const [helpOpen, setHelpOpen] = useState(false);
|
|
|
|
const handleTest = async () => {
|
|
setTesting(true);
|
|
setResult(null);
|
|
try {
|
|
const res = (await apiFetch('/api/v1/admin/embedded-signing/test', {
|
|
method: 'POST',
|
|
body: {},
|
|
})) as {
|
|
data: {
|
|
ok: boolean;
|
|
host?: string;
|
|
error?: string;
|
|
checks?: Array<{ path: string; status?: number; ok: boolean; error?: string }>;
|
|
};
|
|
};
|
|
setResult({ ...res.data, at: new Date() });
|
|
if (res.data.ok) toast.success('Embedded signing host reachable.');
|
|
else toast.error('Embedded signing host probe failed - see card.');
|
|
} catch (err) {
|
|
toastError(err);
|
|
setResult({
|
|
ok: false,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
at: new Date(),
|
|
});
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<CardTitle>Embedded signing</CardTitle>
|
|
<CardDescription>
|
|
Where the public-facing branded signing pages live. The CRM rewrites Documenso
|
|
signing URLs to point here when sending invitation and reminder emails.
|
|
</CardDescription>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setHelpOpen(true)}
|
|
className="gap-1.5 [&_svg]:size-3.5"
|
|
>
|
|
<HelpCircle />
|
|
Setup help
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* Renders inside our outer Card with its own micro-header.
|
|
Title kept terse (empty string would look broken) so the
|
|
user still has a visual anchor for the field. */}
|
|
<SettingsFormCard title="Host URL" description="" fields={EMBED_FIELDS} />
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleTest}
|
|
disabled={testing}
|
|
className="gap-1.5 [&_svg]:size-3.5"
|
|
>
|
|
{testing ? <Loader2 className="animate-spin" aria-hidden /> : null}
|
|
Test connection
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
Probes <code>/</code> and <code>/sign/success</code> on the configured host.
|
|
</p>
|
|
</div>
|
|
|
|
{result ? (
|
|
<div
|
|
className={`rounded-md border p-3 text-sm ${
|
|
result.ok
|
|
? 'border-emerald-200 bg-emerald-50 text-emerald-900'
|
|
: 'border-rose-200 bg-rose-50 text-rose-900'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
{result.ok ? (
|
|
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
) : (
|
|
<XCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
)}
|
|
<div className="flex-1">
|
|
<p className="font-medium">{result.ok ? 'Connection ok' : 'Connection failed'}</p>
|
|
{result.host ? (
|
|
<p className="text-xs">
|
|
Host: <code>{result.host}</code>
|
|
</p>
|
|
) : null}
|
|
{result.error ? <p className="text-xs">{result.error}</p> : null}
|
|
{result.checks ? (
|
|
<ul className="mt-1 space-y-0.5 text-xs">
|
|
{result.checks.map((c) => (
|
|
<li key={c.path}>
|
|
<code>{c.path}</code> →{' '}
|
|
{c.ok ? (
|
|
<span className="text-emerald-800">{c.status ?? 'ok'}</span>
|
|
) : (
|
|
<span className="text-rose-800">
|
|
{c.status ? `${c.status} fail` : (c.error ?? 'fail')}
|
|
</span>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
<p className="mt-1 text-[11px] opacity-70">{result.at.toLocaleTimeString()}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Sheet open={helpOpen} onOpenChange={setHelpOpen}>
|
|
<SheetContent side="right" className="overflow-y-auto sm:max-w-xl">
|
|
<SheetHeader>
|
|
<SheetTitle>Set up embedded signing</SheetTitle>
|
|
<SheetDescription>
|
|
How the marketing site has to be wired up so the branded signing flow works.
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<div className="space-y-4 pt-4 text-sm leading-6">
|
|
<section>
|
|
<h3 className="mb-1 font-semibold">1. Choose the host</h3>
|
|
<p className="text-muted-foreground">
|
|
Pick a public host (e.g. <code>https://portnimara.com</code>) and enter it in the
|
|
Embedded signing host field above. The CRM rewrites raw Documenso signing URLs into{' '}
|
|
<code>{'{host}/sign/<role>/<token>'}</code> for every outbound invitation + reminder
|
|
email.
|
|
</p>
|
|
</section>
|
|
|
|
<section>
|
|
<h3 className="mb-1 font-semibold">2. Implement the signing route</h3>
|
|
<p className="text-muted-foreground">
|
|
The marketing site needs to handle <code>/sign/[role]/[token]</code> by forwarding
|
|
to the underlying Documenso signing URL (or embedding it in an iframe). Role is one
|
|
of <code>client</code> / <code>developer</code> / <code>approver</code> - useful for
|
|
tracking which slot the signer is in.
|
|
</p>
|
|
<p className="mt-1 text-muted-foreground">Minimum Next.js example:</p>
|
|
<pre className="mt-1 overflow-x-auto rounded bg-muted p-2 font-mono text-[11px]">
|
|
{`// app/sign/[role]/[token]/page.tsx
|
|
export default function SignPage({ params }) {
|
|
const documenseUrl = \`\${env.DOCUMENSO_URL}/sign/\${params.token}\`;
|
|
return <iframe src={documenseUrl} className="w-full h-screen" />;
|
|
}`}
|
|
</pre>
|
|
</section>
|
|
|
|
<section>
|
|
<h3 className="mb-1 font-semibold">3. Implement the success route</h3>
|
|
<p className="text-muted-foreground">
|
|
After signing, Documenso redirects to the URL configured in{' '}
|
|
<strong>Post-sign redirect URL</strong>. Default points at{' '}
|
|
<code>{'{host}/sign/success'}</code>. Render a confirmation page there (the
|
|
signer's already done; this is just the friendly “Thanks!” UX).
|
|
</p>
|
|
</section>
|
|
|
|
<section>
|
|
<h3 className="mb-1 font-semibold">4. Test the connection</h3>
|
|
<p className="text-muted-foreground">
|
|
Use the Test connection button to verify <code>/</code> and{' '}
|
|
<code>/sign/success</code> return 2xx. If either fails, the marketing site
|
|
isn't ready - fix the route before flipping live or signers will land on a 404
|
|
page from outbound emails.
|
|
</p>
|
|
</section>
|
|
|
|
<section>
|
|
<h3 className="mb-1 font-semibold">5. Pair the Documenso webhook</h3>
|
|
<p className="text-muted-foreground">
|
|
Make sure the Documenso webhook points at{' '}
|
|
<code>{'{appUrl}/api/webhooks/documenso'}</code> with the matching webhook secret
|
|
stored under Documenso → API → Webhook secret. Without this the EOI status never
|
|
updates after signing.
|
|
</p>
|
|
</section>
|
|
|
|
<section>
|
|
<h3 className="mb-1 font-semibold">6. Cutover</h3>
|
|
<p className="text-muted-foreground">
|
|
Flip the Embedded signing host field to your live URL and save. Existing in-flight
|
|
EOIs keep their pre-cutover signing URLs (the rewrite happens at email-dispatch
|
|
time, not at envelope creation), so old signers can still complete on the old host
|
|
until they sign or the EOI is cancelled.
|
|
</p>
|
|
</section>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|