feat: Swiss-compliant launch — Impressum/AGB/Contact, support panel, DSG exports, cookie banner
All checks were successful
Deploy to Production / deploy (push) Successful in 57s
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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { CookieBanner } from '@/components/cookie-banner';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { MobileActionBar } from '@/components/mobile-action-bar';
|
||||
import { FileClock, LayoutGrid, Package, Server, Settings } from 'lucide-react';
|
||||
@@ -38,6 +39,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
</header>
|
||||
<main className="flex-1 bg-[--color-bg] pb-20 sm:pb-0">{children}</main>
|
||||
<MobileActionBar />
|
||||
<CookieBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
apps/web/app/(dashboard)/settings/account/page.tsx
Normal file
80
apps/web/app/(dashboard)/settings/account/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiUrl } from '@/lib/api';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function AccountPage() {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
async function downloadExport() {
|
||||
setDownloading(true);
|
||||
try {
|
||||
// Trigger a same-origin attachment download. The cookie ships with the
|
||||
// request because we're same-credentials with the API origin via CORS.
|
||||
window.location.href = apiUrl('/v1/account/export');
|
||||
} finally {
|
||||
setTimeout(() => setDownloading(false), 1500);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Account</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
Your data, your rights. Swiss DSG Art. 25 / GDPR Art. 15 + 20.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<section className="panel p-5">
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">Download your data</h2>
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
One JSON file with everything we hold for your account: profile, organization, MCP
|
||||
servers, build history (last 1000 entries), audit log (last 1000 events) and your
|
||||
support-ticket history. Excludes password hashes, encrypted secrets and other
|
||||
users' data.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button variant="primary" size="md" onClick={downloadExport} disabled={downloading}>
|
||||
{downloading ? 'Preparing…' : 'Download .json'}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-5">
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">Delete account</h2>
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
We don't do one-click account deletion yet — too easy to fat-finger and lose
|
||||
paid-tier server configs. Open a ticket and we'll wipe everything within 30
|
||||
days (servers, secrets, audit, tickets) per Swiss DSG Art. 32 / GDPR Art. 17.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/settings/support">
|
||||
<Button variant="secondary" size="md">
|
||||
Open deletion ticket
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel p-5">
|
||||
<h2 className="text-[14px] font-semibold tracking-tight">Cookies on this site</h2>
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
We use only strictly-necessary cookies: a session cookie (
|
||||
<span className="mono">bmm_session</span>, httpOnly, 30 days) and a short-lived
|
||||
OAuth-CSRF state cookie (<span className="mono">bmm_oauth_state</span>, 10 minutes
|
||||
during a third-party login flow). No analytics, no tracking, no third-party cookies on
|
||||
this domain.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-[12px] text-[--color-fg-subtle]">
|
||||
<Link href="/privacy" className="hover:text-[--color-fg]">
|
||||
← Privacy policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
apps/web/app/(dashboard)/settings/support/[id]/page.tsx
Normal file
145
apps/web/app/(dashboard)/settings/support/[id]/page.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { Textarea } from '@/components/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
|
||||
createdAt: string;
|
||||
lastMessageAt: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
authorIsAdmin: boolean;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function TicketDetail() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const [data, setData] = useState<{ ticket: Ticket; messages: Message[] } | null>(null);
|
||||
const [reply, setReply] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
if (!params?.id) return;
|
||||
apiFetch<{ ticket: Ticket; messages: Message[] }>(`/v1/support/tickets/${params.id}`)
|
||||
.then(setData)
|
||||
.catch((e) => setError((e as Error).message));
|
||||
}
|
||||
|
||||
useEffect(load, [params?.id]);
|
||||
|
||||
async function sendReply(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!params?.id || reply.trim().length === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/v1/support/tickets/${params.id}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body: reply }),
|
||||
});
|
||||
setReply('');
|
||||
load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<p className="text-[13px] text-[--color-danger]">{error ?? 'Ticket not found.'}</p>
|
||||
<Link href="/settings/support" className="mt-3 inline-block text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
|
||||
← Back to support
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { ticket, messages } = data;
|
||||
const isClosed = ticket.status === 'closed';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<Link
|
||||
href="/settings/support"
|
||||
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
|
||||
>
|
||||
← All tickets
|
||||
</Link>
|
||||
<div className="mt-3 flex items-baseline justify-between gap-3">
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">{ticket.subject}</h1>
|
||||
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
{ticket.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`panel p-4 ${m.authorIsAdmin ? 'border-[--color-accent]/40' : ''}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span
|
||||
className={`text-[11.5px] font-medium ${m.authorIsAdmin ? 'text-[--color-accent]' : 'text-[--color-fg]'}`}
|
||||
>
|
||||
{m.authorIsAdmin ? 'Support' : 'You'}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-[--color-fg-subtle]">
|
||||
{new Date(m.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-[--color-fg-muted]">
|
||||
{m.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!isClosed && (
|
||||
<form onSubmit={sendReply} className="panel mt-6 space-y-3 p-4">
|
||||
<Textarea
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={10_000}
|
||||
placeholder="Your reply…"
|
||||
/>
|
||||
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={busy || reply.trim().length === 0}
|
||||
>
|
||||
{busy ? 'Sending…' : 'Send reply'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
apps/web/app/(dashboard)/settings/support/page.tsx
Normal file
166
apps/web/app/(dashboard)/settings/support/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { Input, Label, Textarea } from '@/components/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
|
||||
createdAt: string;
|
||||
lastMessageAt: string;
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<Ticket['status'], string> = {
|
||||
awaiting_admin: 'Open — awaiting support',
|
||||
awaiting_user: 'Reply received',
|
||||
closed: 'Closed',
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<Ticket['status'], string> = {
|
||||
awaiting_admin: 'text-amber-300',
|
||||
awaiting_user: 'text-emerald-300',
|
||||
closed: 'text-[--color-fg-subtle]',
|
||||
};
|
||||
|
||||
export default function SupportPage() {
|
||||
const [tickets, setTickets] = useState<Ticket[] | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [subject, setSubject] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
apiFetch<{ tickets: Ticket[] }>('/v1/support/tickets')
|
||||
.then((r) => setTickets(r.tickets))
|
||||
.catch((e) => setError((e as Error).message));
|
||||
}
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
async function createTicket(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch('/v1/support/tickets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ subject, body }),
|
||||
});
|
||||
setSubject('');
|
||||
setBody('');
|
||||
setShowNew(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">Support</h1>
|
||||
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
||||
Open a ticket and we'll get back to you within one business day.
|
||||
</p>
|
||||
</div>
|
||||
{!showNew && (
|
||||
<Button variant="primary" size="md" onClick={() => setShowNew(true)}>
|
||||
+ New ticket
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showNew && (
|
||||
<form onSubmit={createTicket} className="panel mt-6 space-y-4 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="t-subject">Subject</Label>
|
||||
<Input
|
||||
id="t-subject"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={200}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="Briefly — what's up?"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="t-body" hint={`${body.length} / 10000`}>
|
||||
Message
|
||||
</Label>
|
||||
<Textarea
|
||||
id="t-body"
|
||||
required
|
||||
rows={6}
|
||||
minLength={10}
|
||||
maxLength={10_000}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder="The more context the better — server slug, error messages, what you expected."
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="md" type="button" onClick={() => setShowNew(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={busy || subject.length < 3 || body.length < 10}
|
||||
>
|
||||
{busy ? 'Sending…' : 'Open ticket'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
{tickets === null && (
|
||||
<div className="panel p-6 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={18} />
|
||||
</div>
|
||||
)}
|
||||
{tickets && tickets.length === 0 && !showNew && (
|
||||
<div className="panel p-6 text-center text-[13px] text-[--color-fg-muted]">
|
||||
No tickets yet.
|
||||
</div>
|
||||
)}
|
||||
{tickets && tickets.length > 0 && (
|
||||
<div className="panel divide-y divide-[--color-border]">
|
||||
{tickets.map((t) => (
|
||||
<Link
|
||||
key={t.id}
|
||||
href={`/settings/support/${t.id}`}
|
||||
className="flex items-center justify-between px-4 py-3 transition-colors hover:bg-[--color-bg-subtle]"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-[--color-fg]">
|
||||
{t.subject}
|
||||
</div>
|
||||
<div className={`mt-0.5 text-[11.5px] ${STATUS_COLOR[t.status]}`}>
|
||||
{STATUS_LABEL[t.status]} ·{' '}
|
||||
<span className="text-[--color-fg-subtle]">
|
||||
{new Date(t.lastMessageAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="ml-3 text-[--color-fg-subtle]">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
apps/web/app/(marketing)/agb/page.tsx
Normal file
129
apps/web/app/(marketing)/agb/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: 'AGB',
|
||||
description:
|
||||
'Allgemeine Geschäftsbedingungen für die Nutzung von BuildMyMCPServer (Schweiz).',
|
||||
path: '/agb',
|
||||
});
|
||||
|
||||
const SECTIONS: Array<{ h: string; p: string[] }> = [
|
||||
{
|
||||
h: '1. Geltungsbereich',
|
||||
p: [
|
||||
'Diese AGB regeln die Nutzung der über buildmymcpserver.com bereitgestellten Dienste durch natürliche und juristische Personen ("Kund:in"). Mit Erstellung eines Accounts oder Abschluss eines kostenpflichtigen Abonnements bestätigt die Kund:in, diese AGB gelesen, verstanden und akzeptiert zu haben.',
|
||||
'Abweichende Bedingungen der Kund:in gelten nur, wenn schriftlich bestätigt.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '2. Vertragsgegenstand',
|
||||
p: [
|
||||
'BuildMyMCPServer ist ein Software-as-a-Service-Angebot zur Generierung und zum Betrieb von Model-Context-Protocol-Servern (MCP-Server). Der Funktionsumfang ergibt sich aus dem jeweils gewählten Tarif gemäss Pricing-Seite.',
|
||||
'Wir liefern den Service "as-is" mit angestrebter Verfügbarkeit gemäss tariflicher SLA (Hobby/Pro: keine SLA; Team: 99.9% monatlich; Enterprise: vertraglich vereinbart).',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '3. Account und Sicherheit',
|
||||
p: [
|
||||
'Die Kund:in ist verpflichtet, Zugangsdaten vertraulich zu behandeln. Bei Verdacht auf unberechtigten Zugriff sind wir unverzüglich über das Support-Panel zu informieren.',
|
||||
'Wir behalten uns vor, Accounts bei schwerwiegenden Verstössen gegen diese AGB oder geltendes Recht zu suspendieren.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '4. Tarife und Bezahlung',
|
||||
p: [
|
||||
'Bezahlung erfolgt im Voraus über unseren Zahlungsdienstleister Stripe Payments Europe Ltd. (Irland). Akzeptierte Zahlungsmethoden umfassen Kreditkarte und SEPA-Lastschrift.',
|
||||
'Monatliche Tarife werden monatlich, Jahres-Tarife jährlich abgerechnet. Bei Jahres-Tarif werden zwei Monate gratis gewährt.',
|
||||
'Preise verstehen sich vorbehältlich gesetzlicher Mehrwertsteuer. Die anwendbare MwSt. wird durch Stripe Tax automatisch nach Sitz der Kund:in berechnet und ausgewiesen.',
|
||||
'Nutzungs-Overage (Tool-Calls über das tarifliche Kontingent hinaus) wird zu €0.02 / 1000 Calls am Folgemonat in Rechnung gestellt.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '5. Laufzeit, Kündigung und Rückerstattung',
|
||||
p: [
|
||||
'Monats-Abos verlängern sich automatisch um einen Monat, Jahres-Abos um ein Jahr. Eine Kündigung ist jederzeit über das Kundenportal (Stripe) zur nächsten Periode möglich.',
|
||||
'Bereits gezahlte Beträge werden bei Kündigung nicht anteilig rückerstattet; der Service bleibt bis Periodenende aktiv.',
|
||||
'Wir gewähren eine 14-tägige Geld-zurück-Garantie ab Erst-Buchung (nicht bei Verlängerungen). Anfragen über das Support-Panel.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '6. Aussetzung bei Zahlungsverzug',
|
||||
p: [
|
||||
'Bei fehlgeschlagener Zahlung versucht Stripe automatisch Nachzahlungen. Nach drei erfolglosen Versuchen wird der Account in den "suspended"-Status versetzt: Bestehende MCP-Server laufen weiter, jedoch können keine neuen Server angelegt oder Builds gestartet werden.',
|
||||
'Nach erfolgreicher Aktualisierung der Zahlungsmethode wird der Account automatisch reaktiviert.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '7. Daten der Kund:in',
|
||||
p: [
|
||||
'Die Kund:in behält alle Rechte an ihren Inhalten (Prompts, Konfigurationen, Secrets, generierter Code). Wir nutzen diese ausschliesslich zur Erbringung des Dienstes.',
|
||||
'Eine Datenexport-Funktion ist über das Einstellungsmenü verfügbar und entspricht Art. 25 Schweizer Datenschutzgesetz (DSG) sowie Art. 15 DSGVO.',
|
||||
'Details zur Datenverarbeitung siehe unsere Datenschutzerklärung.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '8. KI-Verarbeitung',
|
||||
p: [
|
||||
'Zur Spec-Generierung übermitteln wir Prompt-Texte an unsere KI-Anbieter: Hobby-Tarif → Zhipu AI (China); Pro/Team/Enterprise → Anthropic (USA). Vor Versand keiner sensiblen Daten gilt: Die Kund:in ist verantwortlich, welche Informationen sie in Prompts einfügt.',
|
||||
'Der generierte Code wird statisch auf gefährliche Patterns (eval, child_process, Prompt-Injection-Marker) geprüft, jedoch nicht funktional verifiziert. Die Kund:in prüft den Code vor Produktivnutzung selbst.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '9. Haftung',
|
||||
p: [
|
||||
'Wir haften nur für Schäden, die auf vorsätzlichem oder grob fahrlässigem Verhalten beruhen. Die Haftung für leichte Fahrlässigkeit, Mangelfolgeschäden, entgangenen Gewinn und Drittansprüche ist im gesetzlich zulässigen Umfang ausgeschlossen.',
|
||||
'Wir haften nicht für Inhalte oder Verhalten von Drittanbietern (Anthropic, Zhipu, Stripe, Hetzner u.a.), an die personenbezogene Daten gemäss Datenschutzerklärung übermittelt werden.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '10. Änderungen',
|
||||
p: [
|
||||
'Wir behalten uns vor, diese AGB sowie Preise mit Wirkung für die Zukunft anzupassen. Änderungen werden mindestens 30 Tage vor Inkrafttreten per E-Mail oder im Dashboard angekündigt. Bei Preisanhebung steht der Kund:in ein ausserordentliches Kündigungsrecht zum Wirkungsdatum zu.',
|
||||
],
|
||||
},
|
||||
{
|
||||
h: '11. Anwendbares Recht und Gerichtsstand',
|
||||
p: [
|
||||
'Es gilt schweizerisches Recht unter Ausschluss kollisionsrechtlicher Bestimmungen sowie des UN-Kaufrechts. Ausschliesslicher Gerichtsstand ist der Sitz des Anbieters; zwingende Verbraucher-Gerichtsstände bleiben vorbehalten.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Agb() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<header className="mb-12">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
Allgemeine Geschäftsbedingungen
|
||||
</div>
|
||||
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">AGB</h1>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||
Stand: 2026-05-25. Bei Fragen zur Auslegung erreichst du uns über das{' '}
|
||||
<Link href="/contact" className="text-[--color-accent] underline">
|
||||
Support-Panel
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-9">
|
||||
{SECTIONS.map((s) => (
|
||||
<section key={s.h}>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">{s.h}</h2>
|
||||
<div className="mt-2 space-y-2">
|
||||
{s.p.map((p) => (
|
||||
<p
|
||||
key={p.slice(0, 32)}
|
||||
className="text-[13.5px] leading-relaxed text-[--color-fg-muted]"
|
||||
>
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
apps/web/app/(marketing)/contact/page.tsx
Normal file
133
apps/web/app/(marketing)/contact/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
95
apps/web/app/(marketing)/impressum/page.tsx
Normal file
95
apps/web/app/(marketing)/impressum/page.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { pageMetadata } from '@/lib/seo';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata = pageMetadata({
|
||||
title: 'Impressum',
|
||||
description: 'Legal information for BuildMyMCPServer (Switzerland).',
|
||||
path: '/impressum',
|
||||
});
|
||||
|
||||
export default function Impressum() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-16">
|
||||
<header className="mb-10">
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-[--color-fg-subtle]">
|
||||
Impressum
|
||||
</div>
|
||||
<h1 className="mt-2 text-[32px] font-semibold tracking-tight">Impressum</h1>
|
||||
<p className="mt-3 text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||
Angaben gemäss UWG Art. 3 Abs. 1 lit. s (Schweiz).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">Anbieter</h2>
|
||||
<div className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
<p>BuildMyMCPServer</p>
|
||||
<p>Schweiz</p>
|
||||
<p className="mt-2 text-[12px] text-[--color-fg-subtle]">
|
||||
Postanschrift auf Anfrage über das Support-Panel.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">Kontakt</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
Sämtliche Kontaktanfragen laufen über unser integriertes Support-Panel — ohne
|
||||
Account erreichbar unter{' '}
|
||||
<Link href="/contact" className="text-[--color-accent] underline">
|
||||
/contact
|
||||
</Link>
|
||||
. Eingeloggte Nutzer:innen verwenden{' '}
|
||||
<Link href="/settings/support" className="text-[--color-accent] underline">
|
||||
/settings/support
|
||||
</Link>
|
||||
. Wir antworten in der Regel innerhalb von einem Werktag.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">Mehrwertsteuer</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
UID-Nummer wird im ausgestellten Beleg geführt. Bei steuerrechtlichen Anfragen
|
||||
kontaktiere uns über das Support-Panel.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">Haftungsausschluss</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
Inhalte dieser Webseite werden mit grösstmöglicher Sorgfalt erstellt. Für Richtigkeit,
|
||||
Vollständigkeit und Aktualität wird jedoch keine Gewähr übernommen. Für Inhalte
|
||||
externer Links sind ausschliesslich deren Betreiber verantwortlich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">Anwendbares Recht</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
Es gilt schweizerisches Recht unter Ausschluss kollisionsrechtlicher Bestimmungen.
|
||||
Gerichtsstand ist der Sitz des Anbieters.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-[16px] font-semibold tracking-tight">Weiterführend</h2>
|
||||
<p className="mt-2 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
<Link href="/privacy" className="text-[--color-accent] underline">
|
||||
Datenschutzerklärung
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link href="/agb" className="text-[--color-accent] underline">
|
||||
AGB
|
||||
</Link>{' '}
|
||||
·{' '}
|
||||
<Link href="/security" className="text-[--color-accent] underline">
|
||||
Security
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CookieBanner } from '@/components/cookie-banner';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { MarketingAuthButtons } from '@/components/marketing-auth-buttons';
|
||||
import { MarketingMobileMenu } from '@/components/marketing-mobile-menu';
|
||||
@@ -48,12 +49,21 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
|
||||
<Link href="/docs" className="transition-colors hover:text-[--color-fg]">
|
||||
Docs
|
||||
</Link>
|
||||
<Link href="/contact" className="transition-colors hover:text-[--color-fg]">
|
||||
Contact
|
||||
</Link>
|
||||
<Link href="/security" className="transition-colors hover:text-[--color-fg]">
|
||||
Security
|
||||
</Link>
|
||||
<Link href="/privacy" className="transition-colors hover:text-[--color-fg]">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="/agb" className="transition-colors hover:text-[--color-fg]">
|
||||
AGB
|
||||
</Link>
|
||||
<Link href="/impressum" className="transition-colors hover:text-[--color-fg]">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/terms" className="transition-colors hover:text-[--color-fg]">
|
||||
Terms
|
||||
</Link>
|
||||
@@ -61,6 +71,7 @@ export default function MarketingLayout({ children }: { children: React.ReactNod
|
||||
<div>© {new Date().getFullYear()} BuildMyMCPServer</div>
|
||||
</div>
|
||||
</footer>
|
||||
<CookieBanner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,10 @@ const SECTIONS = [
|
||||
p: [
|
||||
"Anthropic, USA (Claude AI — used for prompt analysis and code generation on Pro / Team / Enterprise tiers). Only the prompt text and resulting spec are sent. Anthropic's data-retention policy applies.",
|
||||
'Zhipu AI, China (GLM model — used for prompt analysis on the free Hobby tier only). Only the prompt text and resulting spec are sent. Upgrade to a paid tier to keep all AI processing within Anthropic (US).',
|
||||
'Hetzner, Germany (compute).',
|
||||
'Stripe Payments Europe Ltd., Ireland (billing, invoicing, payment processing, automatic VAT). Stripe receives: email, billing address, payment method details. Card numbers are tokenised by Stripe and never reach our servers. Stripe is GDPR-compliant and Swiss-DSG-aligned via the EU-Swiss adequacy decision.',
|
||||
'Hetzner, Germany (compute, Postgres, Redis, runner containers).',
|
||||
'Backblaze, EU (encrypted backups).',
|
||||
'Stripe, Ireland (billing).',
|
||||
'Cloudflare (DNS + DDoS protection).',
|
||||
'Cloudflare (DNS + DDoS protection + TLS termination).',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
197
apps/web/app/admin/support/[id]/page.tsx
Normal file
197
apps/web/app/admin/support/[id]/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { Textarea } from '@/components/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Ticket {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
|
||||
guestEmail: string | null;
|
||||
createdAt: string;
|
||||
lastMessageAt: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
authorIsAdmin: boolean;
|
||||
body: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Detail {
|
||||
ticket: Ticket;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export default function AdminTicketDetail() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const [data, setData] = useState<Detail | null>(null);
|
||||
const [reply, setReply] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function load() {
|
||||
if (!params?.id) return;
|
||||
apiFetch<Detail>(`/v1/admin/support/tickets/${params.id}`)
|
||||
.then(setData)
|
||||
.catch((e) => setError((e as Error).message));
|
||||
}
|
||||
|
||||
useEffect(load, [params?.id]);
|
||||
|
||||
async function sendReply(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!params?.id || reply.trim().length === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/v1/admin/support/tickets/${params.id}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ body: reply }),
|
||||
});
|
||||
setReply('');
|
||||
load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(status: Ticket['status']) {
|
||||
if (!params?.id) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/v1/admin/support/tickets/${params.id}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
load();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={20} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-12">
|
||||
<p className="text-[13px] text-[--color-danger]">{error ?? 'Ticket not found.'}</p>
|
||||
<Link href="/admin/support" className="mt-3 inline-block text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
|
||||
← Back to tickets
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { ticket, messages, userEmail, userName } = data;
|
||||
const fromLabel = userEmail
|
||||
? `${userName ? `${userName} · ` : ''}${userEmail}`
|
||||
: ticket.guestEmail
|
||||
? `${ticket.guestEmail} (guest)`
|
||||
: 'unknown';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<Link
|
||||
href="/admin/support"
|
||||
className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]"
|
||||
>
|
||||
← All tickets
|
||||
</Link>
|
||||
<div className="mt-3 flex items-baseline justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">{ticket.subject}</h1>
|
||||
<p className="mt-1 text-[12px] text-[--color-fg-muted]">From: {fromLabel}</p>
|
||||
</div>
|
||||
<span className="mono text-[10.5px] uppercase tracking-wider text-[--color-fg-subtle]">
|
||||
{ticket.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`panel p-4 ${m.authorIsAdmin ? 'border-[--color-accent]/40' : ''}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span
|
||||
className={`text-[11.5px] font-medium ${m.authorIsAdmin ? 'text-[--color-accent]' : 'text-[--color-fg]'}`}
|
||||
>
|
||||
{m.authorIsAdmin ? 'Admin' : 'User'}
|
||||
</span>
|
||||
<span className="text-[10.5px] text-[--color-fg-subtle]">
|
||||
{new Date(m.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 whitespace-pre-wrap text-[13px] leading-relaxed text-[--color-fg-muted]">
|
||||
{m.body}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={sendReply} className="panel mt-6 space-y-3 p-4">
|
||||
<Textarea
|
||||
value={reply}
|
||||
onChange={(e) => setReply(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={10_000}
|
||||
placeholder="Reply to user…"
|
||||
/>
|
||||
{error && <p className="text-[12.5px] text-[--color-danger]">{error}</p>}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setStatus('closed')}
|
||||
disabled={busy || ticket.status === 'closed'}
|
||||
>
|
||||
Mark closed
|
||||
</Button>
|
||||
{ticket.status === 'closed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setStatus('awaiting_admin')}
|
||||
disabled={busy}
|
||||
>
|
||||
Reopen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
type="submit"
|
||||
disabled={busy || reply.trim().length === 0}
|
||||
>
|
||||
{busy ? 'Sending…' : 'Send reply'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
apps/web/app/admin/support/page.tsx
Normal file
132
apps/web/app/admin/support/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AdminTicketRow {
|
||||
ticket: {
|
||||
id: string;
|
||||
subject: string;
|
||||
status: 'awaiting_admin' | 'awaiting_user' | 'closed';
|
||||
guestEmail: string | null;
|
||||
createdAt: string;
|
||||
lastMessageAt: string;
|
||||
};
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<AdminTicketRow['ticket']['status'], string> = {
|
||||
awaiting_admin: 'bg-amber-500/20 text-amber-300',
|
||||
awaiting_user: 'bg-emerald-500/20 text-emerald-300',
|
||||
closed: 'bg-[--color-bg-subtle] text-[--color-fg-subtle]',
|
||||
};
|
||||
|
||||
export default function AdminSupport() {
|
||||
const [rows, setRows] = useState<AdminTicketRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<'all' | 'awaiting_admin' | 'awaiting_user' | 'closed'>(
|
||||
'awaiting_admin',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ tickets: AdminTicketRow[] }>('/v1/admin/support/tickets')
|
||||
.then((r) => setRows(r.tickets))
|
||||
.catch((e) => setError((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const filtered = (rows ?? []).filter(
|
||||
(r) => filter === 'all' || r.ticket.status === filter,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<Link href="/admin" className="text-[12px] text-[--color-fg-muted] hover:text-[--color-fg]">
|
||||
← Admin
|
||||
</Link>
|
||||
<h1 className="mt-1 text-[22px] font-semibold tracking-tight">Support tickets</h1>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(['awaiting_admin', 'awaiting_user', 'closed', 'all'] as const).map((s) => (
|
||||
<button
|
||||
type="button"
|
||||
key={s}
|
||||
onClick={() => setFilter(s)}
|
||||
className={`rounded-md px-2.5 py-1 text-[11.5px] transition-colors ${
|
||||
filter === s
|
||||
? 'bg-[--color-bg-subtle] text-[--color-fg]'
|
||||
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
|
||||
}`}
|
||||
>
|
||||
{s.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-4 text-[12.5px] text-[--color-danger]">{error}</p>}
|
||||
|
||||
<div className="panel mt-6">
|
||||
{rows === null && (
|
||||
<div className="p-6 text-center">
|
||||
<Loader2 className="mx-auto animate-spin text-[--color-fg-muted]" size={18} />
|
||||
</div>
|
||||
)}
|
||||
{rows && filtered.length === 0 && (
|
||||
<div className="p-6 text-center text-[13px] text-[--color-fg-muted]">
|
||||
No tickets in this view.
|
||||
</div>
|
||||
)}
|
||||
{rows && filtered.length > 0 && (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead className="border-b border-[--color-border] text-[--color-fg-subtle]">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Subject</th>
|
||||
<th className="px-4 py-2 text-left font-medium">From</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Last activity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r) => (
|
||||
<tr
|
||||
key={r.ticket.id}
|
||||
className="border-b border-[--color-border] last:border-0 hover:bg-[--color-bg-subtle]"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/admin/support/${r.ticket.id}`}
|
||||
className="font-medium hover:text-[--color-accent]"
|
||||
>
|
||||
{r.ticket.subject}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="mono px-4 py-2.5 text-[--color-fg-muted]">
|
||||
{r.userEmail ??
|
||||
(r.ticket.guestEmail
|
||||
? `${r.ticket.guestEmail} (guest)`
|
||||
: 'unknown')}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10.5px] ${STATUS_BADGE[r.ticket.status]}`}
|
||||
>
|
||||
{r.ticket.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-[--color-fg-muted]">
|
||||
{new Date(r.ticket.lastMessageAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
apps/web/components/cookie-banner.tsx
Normal file
69
apps/web/components/cookie-banner.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STORAGE_KEY = 'bmm-cookie-ack-v1';
|
||||
|
||||
/**
|
||||
* BMM uses only strictly-necessary cookies (session + OAuth CSRF state). Under
|
||||
* Swiss DSG and GDPR strictly-necessary cookies do not require opt-in consent,
|
||||
* only clear disclosure — this banner satisfies the disclosure obligation
|
||||
* without dark-pattern cookie walls or false-choice "Reject all" UIs.
|
||||
*/
|
||||
export function CookieBanner() {
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const ack = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!ack) setShow(true);
|
||||
} catch {
|
||||
// localStorage blocked (private mode etc.) — show banner anyway, it's
|
||||
// dismissable via a single click and never persists if storage fails.
|
||||
setShow(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function acknowledge() {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, new Date().toISOString());
|
||||
} catch {
|
||||
/* ignore — re-shown on next visit */
|
||||
}
|
||||
setShow(false);
|
||||
}
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Cookie notice"
|
||||
className="fixed inset-x-0 bottom-0 z-50 border-t border-[--color-border] backdrop-blur-md"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 92%, transparent)',
|
||||
paddingBottom: 'max(env(safe-area-inset-bottom), 0.75rem)',
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-3 px-5 pt-3 sm:flex-row sm:items-center sm:gap-4 sm:px-6">
|
||||
<p className="flex-1 text-[12.5px] leading-relaxed text-[--color-fg-muted]">
|
||||
We use strictly-necessary cookies for login (session token) and CSRF
|
||||
protection. No tracking, no analytics, no third-party cookies on this
|
||||
domain. Details:{' '}
|
||||
<Link href="/privacy" className="text-[--color-accent] hover:underline">
|
||||
privacy policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledge}
|
||||
className="inline-flex h-9 shrink-0 items-center justify-center rounded-md bg-[--color-accent] px-4 text-[13px] font-medium text-white transition-colors hover:bg-[#5557e8]"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user