From ef30baf52aee6d78288b392fa4822f171c1658b9 Mon Sep 17 00:00:00 2001 From: Marco Sadjadi Date: Mon, 25 May 2026 17:12:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Swiss-compliant=20launch=20=E2=80=94=20?= =?UTF-8?q?Impressum/AGB/Contact,=20support=20panel,=20DSG=20exports,=20co?= =?UTF-8?q?okie=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/index.ts | 4 + apps/api/src/lib/plan.ts | 25 ++ apps/api/src/routes/account.ts | 137 +++++++++ apps/api/src/routes/servers.ts | 28 +- apps/api/src/routes/support.ts | 290 ++++++++++++++++++ apps/web/app/(dashboard)/layout.tsx | 2 + .../app/(dashboard)/settings/account/page.tsx | 80 +++++ .../settings/support/[id]/page.tsx | 145 +++++++++ .../app/(dashboard)/settings/support/page.tsx | 166 ++++++++++ apps/web/app/(marketing)/agb/page.tsx | 129 ++++++++ apps/web/app/(marketing)/contact/page.tsx | 133 ++++++++ apps/web/app/(marketing)/impressum/page.tsx | 95 ++++++ apps/web/app/(marketing)/layout.tsx | 11 + apps/web/app/(marketing)/privacy/page.tsx | 6 +- apps/web/app/admin/support/[id]/page.tsx | 197 ++++++++++++ apps/web/app/admin/support/page.tsx | 132 ++++++++ apps/web/components/cookie-banner.tsx | 69 +++++ packages/db/src/schema.ts | 49 +++ 18 files changed, 1692 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/routes/account.ts create mode 100644 apps/api/src/routes/support.ts create mode 100644 apps/web/app/(dashboard)/settings/account/page.tsx create mode 100644 apps/web/app/(dashboard)/settings/support/[id]/page.tsx create mode 100644 apps/web/app/(dashboard)/settings/support/page.tsx create mode 100644 apps/web/app/(marketing)/agb/page.tsx create mode 100644 apps/web/app/(marketing)/contact/page.tsx create mode 100644 apps/web/app/(marketing)/impressum/page.tsx create mode 100644 apps/web/app/admin/support/[id]/page.tsx create mode 100644 apps/web/app/admin/support/page.tsx create mode 100644 apps/web/components/cookie-banner.tsx diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7fbb4a8..b427da0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -6,12 +6,14 @@ import Fastify from 'fastify'; import { config } from './config.js'; import { ensureActiveKey } from './lib/crypto.js'; import { validateStripePriceConfig } from './lib/stripe.js'; +import { accountRoutes } from './routes/account.js'; import { adminRoutes } from './routes/admin.js'; import { authRoutes } from './routes/auth.js'; import { billingRoutes } from './routes/billing.js'; import { oauthRoutes } from './routes/oauth.js'; import { serverRoutes } from './routes/servers.js'; import { settingsRoutes } from './routes/settings.js'; +import { supportRoutes } from './routes/support.js'; import { templateRoutes } from './routes/templates.js'; // Stripe webhook signature verification requires the raw request body, so we @@ -72,6 +74,8 @@ await app.register(settingsRoutes); await app.register(adminRoutes); await app.register(templateRoutes); await app.register(billingRoutes); +await app.register(supportRoutes); +await app.register(accountRoutes); // Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…) // instead of price ids (price_…). Stripe Checkout would silently 400 — easier diff --git a/apps/api/src/lib/plan.ts b/apps/api/src/lib/plan.ts index 02c3c7a..3feec66 100644 --- a/apps/api/src/lib/plan.ts +++ b/apps/api/src/lib/plan.ts @@ -14,6 +14,31 @@ export async function getOrgPlan(orgId: string): Promise { return (row?.plan ?? 'hobby') as Plan; } +export interface OrgBilling { + plan: Plan; + suspended: boolean; + suspendedReason: string | null; +} + +/** Like getOrgPlan but also reports suspension state. Use in routes that + * should refuse new work when a subscription is past-due / unpaid. */ +export async function getOrgBilling(orgId: string): Promise { + const [row] = await db + .select({ + plan: organizations.plan, + suspended: organizations.suspended, + suspendedReason: organizations.suspendedReason, + }) + .from(organizations) + .where(eq(organizations.id, orgId)) + .limit(1); + return { + plan: (row?.plan ?? 'hobby') as Plan, + suspended: row?.suspended ?? false, + suspendedReason: row?.suspendedReason ?? null, + }; +} + /** Max MCP servers per org by plan. Enforced at POST /v1/servers. */ export const SERVER_LIMITS: Record = { hobby: 1, diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts new file mode 100644 index 0000000..2019a4f --- /dev/null +++ b/apps/api/src/routes/account.ts @@ -0,0 +1,137 @@ +import { + auditLog, + builds, + createDb, + desc, + eq, + inArray, + mcpServers, + organizations, + supportMessages, + supportTickets, + users, +} from '@bmm/db'; +import type { FastifyInstance } from 'fastify'; +import { audit } from '../lib/audit.js'; +import { requireAuth } from '../plugins/session.js'; + +const db = createDb(); + +export async function accountRoutes(app: FastifyInstance): Promise { + /** + * GDPR Art. 15 / Swiss DSG Art. 25 — right of access. Returns every record + * we hold that belongs to the calling user. Excludes hashed passwords, + * encrypted secret payloads, and any other user's data. Streamed as JSON + * attachment so the browser downloads it directly. + */ + app.get('/v1/account/export', { preHandler: requireAuth }, async (req, reply) => { + const user = req.user!; + + const [userRow] = await db.select().from(users).where(eq(users.id, user.userId)).limit(1); + const [org] = await db + .select() + .from(organizations) + .where(eq(organizations.id, user.orgId)) + .limit(1); + const orgServers = await db + .select() + .from(mcpServers) + .where(eq(mcpServers.orgId, user.orgId)); + const serverIds = orgServers.map((s) => s.id); + const orgBuilds = + serverIds.length > 0 + ? await db.select().from(builds).where(inArray(builds.serverId, serverIds)) + : []; + const userAudit = await db + .select() + .from(auditLog) + .where(eq(auditLog.userId, user.userId)) + .orderBy(desc(auditLog.createdAt)) + .limit(1000); + const userTickets = await db + .select() + .from(supportTickets) + .where(eq(supportTickets.userId, user.userId)); + const ticketIds = userTickets.map((t) => t.id); + const userTicketMessages = + ticketIds.length > 0 + ? await db + .select() + .from(supportMessages) + .where(inArray(supportMessages.ticketId, ticketIds)) + : []; + + await audit({ + orgId: user.orgId, + userId: user.userId, + action: 'account.export', + resourceType: 'account', + ipAddress: req.ip, + }); + + reply + .header('Content-Type', 'application/json; charset=utf-8') + .header( + 'Content-Disposition', + `attachment; filename="buildmymcpserver-export-${Date.now()}.json"`, + ); + + return reply.send({ + exportedAt: new Date().toISOString(), + _format: 'BuildMyMCPServer Account Export v1', + _excluded: [ + 'password hashes', + 'encrypted secret payloads', + 'session tokens', + 'other users in the same organization', + ], + user: userRow + ? { + id: userRow.id, + email: userRow.email, + name: userRow.name, + phone: userRow.phone, + isAdmin: userRow.isAdmin, + createdAt: userRow.createdAt, + } + : null, + organization: org + ? { + id: org.id, + slug: org.slug, + name: org.name, + plan: org.plan, + createdAt: org.createdAt, + } + : null, + servers: orgServers.map((s) => ({ + id: s.id, + slug: s.slug, + name: s.name, + status: s.status, + publicUrl: s.publicUrl, + toolsSchema: s.toolsSchema, + createdAt: s.createdAt, + })), + builds: orgBuilds.map((b) => ({ + id: b.id, + serverId: b.serverId, + version: b.version, + prompt: b.prompt, + status: b.status, + createdAt: b.createdAt, + })), + audit: userAudit.map((a) => ({ + id: a.id, + action: a.action, + resourceType: a.resourceType, + resourceId: a.resourceId, + metadata: a.metadata, + ipAddress: a.ipAddress, + createdAt: a.createdAt, + })), + supportTickets: userTickets, + supportMessages: userTicketMessages, + }); + }); +} diff --git a/apps/api/src/routes/servers.ts b/apps/api/src/routes/servers.ts index c54b780..261b99f 100644 --- a/apps/api/src/routes/servers.ts +++ b/apps/api/src/routes/servers.ts @@ -32,7 +32,7 @@ import { config } from '../config.js'; import { audit } from '../lib/audit.js'; import { encryptSecret } from '../lib/crypto.js'; import { stopContainer } from '../lib/docker.js'; -import { SERVER_LIMITS, getOrgPlan } from '../lib/plan.js'; +import { SERVER_LIMITS, getOrgBilling } from '../lib/plan.js'; import { cacheSpec, loadSpec, overwriteSpec } from '../lib/preview-cache.js'; import { getBuildQueue } from '../lib/queue.js'; import { BUILD_DAILY_LIMIT, PREVIEW_DAILY_LIMIT, checkDailyLimit } from '../lib/rate-limit.js'; @@ -60,7 +60,18 @@ export async function serverRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'invalid_input', issues: parsed.error.flatten() }); } - const plan = await getOrgPlan(user.orgId); + const billing = await getOrgBilling(user.orgId); + if (billing.suspended) { + return reply.code(402).send({ + error: 'subscription_suspended', + detail: + billing.suspendedReason === 'payment_failed' + ? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.' + : 'Your subscription is paused. Visit /settings/billing for details.', + suspendedReason: billing.suspendedReason, + }); + } + const plan = billing.plan; // Daily preview rate-limit per user. Free is tight (5/day) because every // preview is a paid LLM call; paid tiers have headroom for real iteration. @@ -142,7 +153,18 @@ export async function serverRoutes(app: FastifyInstance): Promise { } = parsed.data; // ---- Plan enforcement (must happen before any DB write) ---- - const plan = await getOrgPlan(user.orgId); + const billing = await getOrgBilling(user.orgId); + if (billing.suspended) { + return reply.code(402).send({ + error: 'subscription_suspended', + detail: + billing.suspendedReason === 'payment_failed' + ? 'Your subscription is paused due to a payment issue. Update your payment method in /settings/billing.' + : 'Your subscription is paused. Visit /settings/billing for details.', + suspendedReason: billing.suspendedReason, + }); + } + const plan = billing.plan; // Daily build rate-limit. const rl = await checkDailyLimit('build', user.userId, BUILD_DAILY_LIMIT[plan]); diff --git a/apps/api/src/routes/support.ts b/apps/api/src/routes/support.ts new file mode 100644 index 0000000..d7cb224 --- /dev/null +++ b/apps/api/src/routes/support.ts @@ -0,0 +1,290 @@ +import { + and, + createDb, + desc, + eq, + supportMessages, + supportTickets, + users, +} from '@bmm/db'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { audit } from '../lib/audit.js'; +import { checkDailyLimit } from '../lib/rate-limit.js'; +import { requireAdmin, requireAuth } from '../plugins/session.js'; + +const db = createDb(); + +const NewTicketBody = z.object({ + subject: z.string().min(3).max(200), + body: z.string().min(10).max(10_000), +}); + +const GuestTicketBody = z.object({ + email: z.string().email(), + subject: z.string().min(3).max(200), + body: z.string().min(10).max(10_000), +}); + +const NewMessageBody = z.object({ + body: z.string().min(1).max(10_000), +}); + +const StatusBody = z.object({ + status: z.enum(['awaiting_admin', 'awaiting_user', 'closed']), +}); + +export async function supportRoutes(app: FastifyInstance): Promise { + // ─── User-side ────────────────────────────────────────────────────────── + app.post('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => { + const user = req.user!; + const parsed = NewTicketBody.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + + const [ticket] = await db + .insert(supportTickets) + .values({ + userId: user.userId, + orgId: user.orgId, + subject: parsed.data.subject, + status: 'awaiting_admin', + }) + .returning(); + if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' }); + + await db.insert(supportMessages).values({ + ticketId: ticket.id, + authorUserId: user.userId, + authorIsAdmin: false, + body: parsed.data.body, + }); + + await audit({ + orgId: user.orgId, + userId: user.userId, + action: 'support.ticket_created', + resourceType: 'support_ticket', + resourceId: ticket.id, + metadata: { subject: parsed.data.subject }, + ipAddress: req.ip, + }); + + return reply.send({ ticket }); + }); + + app.get('/v1/support/tickets', { preHandler: requireAuth }, async (req, reply) => { + const user = req.user!; + const rows = await db + .select() + .from(supportTickets) + .where(eq(supportTickets.userId, user.userId)) + .orderBy(desc(supportTickets.lastMessageAt)); + return reply.send({ tickets: rows }); + }); + + app.get('/v1/support/tickets/:id', { preHandler: requireAuth }, async (req, reply) => { + const user = req.user!; + const Params = z.object({ id: z.string().uuid() }); + const parsed = Params.safeParse(req.params); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); + + const [ticket] = await db + .select() + .from(supportTickets) + .where( + and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)), + ) + .limit(1); + if (!ticket) return reply.code(404).send({ error: 'not_found' }); + + const messages = await db + .select() + .from(supportMessages) + .where(eq(supportMessages.ticketId, ticket.id)) + .orderBy(supportMessages.createdAt); + + return reply.send({ ticket, messages }); + }); + + app.post( + '/v1/support/tickets/:id/messages', + { preHandler: requireAuth }, + async (req, reply) => { + const user = req.user!; + const Params = z.object({ id: z.string().uuid() }); + const parsed = Params.safeParse(req.params); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); + const body = NewMessageBody.safeParse(req.body); + if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); + + const [ticket] = await db + .select() + .from(supportTickets) + .where( + and(eq(supportTickets.id, parsed.data.id), eq(supportTickets.userId, user.userId)), + ) + .limit(1); + if (!ticket) return reply.code(404).send({ error: 'not_found' }); + + await db.insert(supportMessages).values({ + ticketId: ticket.id, + authorUserId: user.userId, + authorIsAdmin: false, + body: body.data.body, + }); + + await db + .update(supportTickets) + .set({ + status: 'awaiting_admin', + lastMessageAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(supportTickets.id, ticket.id)); + + return reply.send({ ok: true }); + }, + ); + + // ─── Public contact form (no auth) ───────────────────────────────────── + // Satisfies UWG Art. 3 lit. s ("easy electronic contact") for non-logged-in + // visitors. Rate-limited per IP to prevent spam-flood of admin queue. + app.post('/v1/contact', async (req, reply) => { + const parsed = GuestTicketBody.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + + const rl = await checkDailyLimit('contact', req.ip, 3); + if (!rl.ok) { + return reply.code(429).send({ + error: 'rate_limited', + detail: 'Too many contact submissions from this IP. Try again tomorrow.', + }); + } + + const [ticket] = await db + .insert(supportTickets) + .values({ + guestEmail: parsed.data.email, + subject: parsed.data.subject, + status: 'awaiting_admin', + }) + .returning(); + if (!ticket) return reply.code(500).send({ error: 'ticket_create_failed' }); + + await db.insert(supportMessages).values({ + ticketId: ticket.id, + authorUserId: null, + authorIsAdmin: false, + body: parsed.data.body, + }); + + return reply.send({ ok: true }); + }); + + // ─── Admin-side ──────────────────────────────────────────────────────── + app.get( + '/v1/admin/support/tickets', + { preHandler: requireAdmin }, + async (_req, reply) => { + const rows = await db + .select({ + ticket: supportTickets, + userEmail: users.email, + userName: users.name, + }) + .from(supportTickets) + .leftJoin(users, eq(users.id, supportTickets.userId)) + .orderBy(desc(supportTickets.lastMessageAt)) + .limit(200); + return reply.send({ tickets: rows }); + }, + ); + + app.get( + '/v1/admin/support/tickets/:id', + { preHandler: requireAdmin }, + async (req, reply) => { + const Params = z.object({ id: z.string().uuid() }); + const parsed = Params.safeParse(req.params); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); + + const [row] = await db + .select({ ticket: supportTickets, userEmail: users.email, userName: users.name }) + .from(supportTickets) + .leftJoin(users, eq(users.id, supportTickets.userId)) + .where(eq(supportTickets.id, parsed.data.id)) + .limit(1); + if (!row) return reply.code(404).send({ error: 'not_found' }); + + const messages = await db + .select() + .from(supportMessages) + .where(eq(supportMessages.ticketId, parsed.data.id)) + .orderBy(supportMessages.createdAt); + + return reply.send({ ticket: row.ticket, userEmail: row.userEmail, userName: row.userName, messages }); + }, + ); + + app.post( + '/v1/admin/support/tickets/:id/messages', + { preHandler: requireAdmin }, + async (req, reply) => { + const user = req.user!; + const Params = z.object({ id: z.string().uuid() }); + const parsed = Params.safeParse(req.params); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); + const body = NewMessageBody.safeParse(req.body); + if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); + + await db.insert(supportMessages).values({ + ticketId: parsed.data.id, + authorUserId: user.userId, + authorIsAdmin: true, + body: body.data.body, + }); + + await db + .update(supportTickets) + .set({ + status: 'awaiting_user', + lastMessageAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(supportTickets.id, parsed.data.id)); + + await audit({ + orgId: user.orgId, + userId: user.userId, + action: 'support.admin_reply', + resourceType: 'support_ticket', + resourceId: parsed.data.id, + }); + + return reply.send({ ok: true }); + }, + ); + + app.post( + '/v1/admin/support/tickets/:id/status', + { preHandler: requireAdmin }, + async (req, reply) => { + const Params = z.object({ id: z.string().uuid() }); + const parsed = Params.safeParse(req.params); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_id' }); + const body = StatusBody.safeParse(req.body); + if (!body.success) return reply.code(400).send({ error: 'invalid_input' }); + + await db + .update(supportTickets) + .set({ + status: body.data.status, + closedAt: body.data.status === 'closed' ? new Date() : null, + updatedAt: new Date(), + }) + .where(eq(supportTickets.id, parsed.data.id)); + + return reply.send({ ok: true }); + }, + ); +} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 2140380..c14f764 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -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
{children}
+ ); } diff --git a/apps/web/app/(dashboard)/settings/account/page.tsx b/apps/web/app/(dashboard)/settings/account/page.tsx new file mode 100644 index 0000000..17ba5d3 --- /dev/null +++ b/apps/web/app/(dashboard)/settings/account/page.tsx @@ -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 ( +
+

Account

+

+ Your data, your rights. Swiss DSG Art. 25 / GDPR Art. 15 + 20. +

+ +
+
+

Download your data

+

+ 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. +

+
+ +
+
+ +
+

Delete account

+

+ 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. +

+
+ + + +
+
+ +
+

Cookies on this site

+

+ We use only strictly-necessary cookies: a session cookie ( + bmm_session, httpOnly, 30 days) and a short-lived + OAuth-CSRF state cookie (bmm_oauth_state, 10 minutes + during a third-party login flow). No analytics, no tracking, no third-party cookies on + this domain. +

+
+
+ +
+ + ← Privacy policy + +
+
+ ); +} diff --git a/apps/web/app/(dashboard)/settings/support/[id]/page.tsx b/apps/web/app/(dashboard)/settings/support/[id]/page.tsx new file mode 100644 index 0000000..70352a6 --- /dev/null +++ b/apps/web/app/(dashboard)/settings/support/[id]/page.tsx @@ -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(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 ( +
+ +
+ ); + } + + if (error || !data) { + return ( +
+

{error ?? 'Ticket not found.'}

+ + ← Back to support + +
+ ); + } + + const { ticket, messages } = data; + const isClosed = ticket.status === 'closed'; + + return ( +
+ + ← All tickets + +
+

{ticket.subject}

+ + {ticket.status.replace('_', ' ')} + +
+ +
+ {messages.map((m) => ( +
+
+ + {m.authorIsAdmin ? 'Support' : 'You'} + + + {new Date(m.createdAt).toLocaleString()} + +
+

+ {m.body} +

+
+ ))} +
+ + {!isClosed && ( +
+