All checks were successful
Deploy to Production / deploy (push) Successful in 57s
Legal (Swiss minimum, no individual named): - Impressum page (UWG Art. 3 lit. s) — provider, contact via support panel, no email required, jurisdiction = Switzerland - AGB page — subscription terms, payment, cancellation, suspension on payment fail, 14-day money-back, AI-processing-per-tier disclosure, Swiss law + Swiss venue, modeled after typical Schweizer SaaS terms - Privacy: Stripe added as subprocessor with full data-flow disclosure Support panel replaces email contact entirely: - @bmm/db: support_status enum + support_tickets + support_messages tables, migration applied to prod DB - @bmm/api: support routes (user create/list/view/reply, admin list/view/reply /set-status), public /v1/contact for logged-out visitors with per-IP rate limit of 3 submissions/day to prevent spam-flood - Web: /settings/support (list + new), /settings/support/[id] (conversation), /admin/support, /admin/support/[id] - Public /contact form with email collection for guest tickets Data rights (DSG Art. 25 / GDPR Art. 15+20): - /v1/account/export returns user-scoped JSON of profile, org, servers, builds, audit, support tickets and messages — excludes hashes, encrypted secrets, other-user data - /settings/account: download button + deletion-via-ticket workflow Production-readiness gaps closed: - org.suspended now blocks /v1/servers POST and /v1/servers/preview (402); webhook flagged this state but enforcement was missing - Cookie banner: minimal, essential-cookies-only disclosure (Swiss DSG + GDPR compliant without dark-pattern consent UI), mounts on both layouts Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
TypeScript
134 lines
4.4 KiB
TypeScript
'use client';
|
|
|
|
import { Input, Label, Textarea } from '@/components/input';
|
|
import { Button } from '@/components/ui/button';
|
|
import { apiFetch } from '@/lib/api';
|
|
import Link from 'next/link';
|
|
import { useState } from 'react';
|
|
|
|
export default function ContactPage() {
|
|
const [email, setEmail] = useState('');
|
|
const [subject, setSubject] = useState('');
|
|
const [body, setBody] = useState('');
|
|
const [state, setState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function submit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setState('sending');
|
|
setError(null);
|
|
try {
|
|
await apiFetch('/v1/contact', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, subject, body }),
|
|
});
|
|
setState('sent');
|
|
} catch (err) {
|
|
setState('error');
|
|
const detail = (err as { detail?: { detail?: string; error?: string } }).detail;
|
|
setError(detail?.detail ?? detail?.error ?? (err as Error).message);
|
|
}
|
|
}
|
|
|
|
if (state === 'sent') {
|
|
return (
|
|
<div className="mx-auto max-w-2xl px-6 py-16">
|
|
<div className="panel p-6 text-center">
|
|
<h1 className="text-[20px] font-semibold tracking-tight">Message received</h1>
|
|
<p className="mt-2 text-[13.5px] text-[--color-fg-muted]">
|
|
Thank you — we got your message. We'll reply to{' '}
|
|
<span className="text-[--color-fg]">{email}</span> within one business day.
|
|
</p>
|
|
<p className="mt-4 text-[12px] text-[--color-fg-subtle]">
|
|
<Link href="/" className="hover:text-[--color-fg]">
|
|
← Back to home
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl px-6 py-14">
|
|
<header className="mb-8">
|
|
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
|
Contact
|
|
</div>
|
|
<h1 className="mt-2 text-[28px] font-semibold tracking-tight">Talk to us</h1>
|
|
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
|
We don't do public email — every conversation runs through our internal support
|
|
panel so nothing gets lost. Already have an account?{' '}
|
|
<Link href="/settings/support" className="text-[--color-accent] hover:underline">
|
|
Open a ticket from inside
|
|
</Link>
|
|
.
|
|
</p>
|
|
</header>
|
|
|
|
<form onSubmit={submit} className="panel space-y-4 p-5">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="contact-email">Your email</Label>
|
|
<Input
|
|
id="contact-email"
|
|
type="email"
|
|
required
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
placeholder="you@company.com"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="contact-subject">Subject</Label>
|
|
<Input
|
|
id="contact-subject"
|
|
required
|
|
minLength={3}
|
|
maxLength={200}
|
|
value={subject}
|
|
onChange={(e) => setSubject(e.target.value)}
|
|
placeholder="Briefly — what's this about?"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="contact-body" hint={`${body.length} / 10000`}>
|
|
Message
|
|
</Label>
|
|
<Textarea
|
|
id="contact-body"
|
|
required
|
|
rows={7}
|
|
minLength={10}
|
|
maxLength={10_000}
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
placeholder="Tell us what's going on. We answer within one business day."
|
|
/>
|
|
</div>
|
|
|
|
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
|
|
|
<div className="flex items-center justify-between pt-1">
|
|
<p className="text-[11px] text-[--color-fg-subtle]">
|
|
Submitting creates a support ticket — see{' '}
|
|
<Link href="/privacy" className="hover:text-[--color-fg]">
|
|
privacy
|
|
</Link>
|
|
.
|
|
</p>
|
|
<Button
|
|
variant="primary"
|
|
size="md"
|
|
type="submit"
|
|
disabled={state === 'sending' || !email || subject.length < 3 || body.length < 10}
|
|
>
|
|
{state === 'sending' ? 'Sending…' : 'Send'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|