From fd63026faf76c8b6c994eeec1e25003481f08dd0 Mon Sep 17 00:00:00 2001 From: Melvin Date: Fri, 10 Jul 2026 07:31:35 -0700 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20marketplace=20MVP=20web-backend=20?= =?UTF-8?q?=E2=80=94=20inquiry-intake,=20web-chat=20gateway,=20reply-notif?= =?UTF-8?q?icaties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web-kant van Epic PolderLabs/ribba.app#35: - Schema-migratie: inquiries, inquiry_recipients, conversations, messages, user_profiles + RLS + realtime (ribba.app#36, met gedocumenteerde afwijkingen) - POST /api/inquiry-submit met CORS, rate limit, honeypot en outreach-mails naar rijscholen via Resend (ribba.app#33) - Web-chat gateway /chat/[token]: OTP-gate via Supabase Auth, claim-flow met server-side e-mailmatch, geanonimiseerde WhatsApp-stijl realtime chat (ribba.app#42) - Smart app banners alleen op chat-pagina's: apple-itunes-app meta + chat-manifest.webmanifest (ribba.app#43) - Reply-notificatie e-mails via 5-min cron met settle-delay, bundeling en opt-out (ribba.app#44) --- app/api/chat/claim/route.ts | 202 ++++++++++++ app/api/chat/resolve/route.ts | 96 ++++++ app/api/cron/chat-notifications/route.ts | 213 +++++++++++++ app/api/inquiry-submit/route.ts | 209 +++++++++++++ app/api/notifications/opt-out/route.ts | 81 +++++ app/chat/[token]/layout.tsx | 29 ++ app/chat/[token]/page.tsx | 6 + app/globals.css | 255 +++++++++++++++ components/chat/ChatGateway.tsx | 211 +++++++++++++ components/chat/ChatThread.tsx | 220 +++++++++++++ components/chat/MessageComposer.tsx | 55 ++++ components/chat/OtpGate.tsx | 111 +++++++ docs/ARCHITECTUUR.md | 10 + lib/cors.ts | 30 ++ lib/marketplace-db.ts | 102 ++++++ lib/marketplace-emails.ts | 238 ++++++++++++++ lib/marketplace-types.ts | 87 +++++ middleware.ts | 1 + public/chat-manifest.webmanifest | 18 ++ .../20260711000000_marketplace_mvp.sql | 296 ++++++++++++++++++ supabase/migrations/README.md | 34 ++ vercel.json | 4 + 22 files changed, 2508 insertions(+) create mode 100644 app/api/chat/claim/route.ts create mode 100644 app/api/chat/resolve/route.ts create mode 100644 app/api/cron/chat-notifications/route.ts create mode 100644 app/api/inquiry-submit/route.ts create mode 100644 app/api/notifications/opt-out/route.ts create mode 100644 app/chat/[token]/layout.tsx create mode 100644 app/chat/[token]/page.tsx create mode 100644 components/chat/ChatGateway.tsx create mode 100644 components/chat/ChatThread.tsx create mode 100644 components/chat/MessageComposer.tsx create mode 100644 components/chat/OtpGate.tsx create mode 100644 lib/cors.ts create mode 100644 lib/marketplace-db.ts create mode 100644 lib/marketplace-emails.ts create mode 100644 lib/marketplace-types.ts create mode 100644 public/chat-manifest.webmanifest create mode 100644 supabase/migrations/20260711000000_marketplace_mvp.sql create mode 100644 supabase/migrations/README.md diff --git a/app/api/chat/claim/route.ts b/app/api/chat/claim/route.ts new file mode 100644 index 0000000..245393f --- /dev/null +++ b/app/api/chat/claim/route.ts @@ -0,0 +1,202 @@ +// Claim-stap van de web-chat gateway (issue ribba.app#42): koppelt een zojuist +// via OTP geverifieerde Supabase-user aan zijn kant van de inquiry en maakt +// (voor de rijschool) de conversatie aan. Het geverifieerde e-mailadres MOET +// matchen met het adres waar we de link naartoe stuurden — dit is tegelijk de +// identiteitskoppeling voor account-continuïteit web → app (ribbaPro#139). + +import { NextRequest, NextResponse } from 'next/server'; +import { rateLimit } from '@/lib/rate-limit'; +import { getServiceClient, lookupRecipientByToken } from '@/lib/marketplace-db'; +import type { ChatRole } from '@/lib/marketplace-types'; + +async function ensureUserProfile( + userId: string, + role: ChatRole, + fullName: string | null, + rijschoolId: number | null, +): Promise { + const supabase = getServiceClient(); + const { data: existing } = await supabase + .from('user_profiles') + .select('user_id, role') + .eq('user_id', userId) + .maybeSingle(); + + // Bestaand profiel NOOIT overschrijven: de auth-pool is gedeeld met de + // native apps en een bestaande rol (bijv. via de app aangemaakt) is leidend. + if (existing) return; + + const { error } = await supabase.from('user_profiles').insert({ + user_id: userId, + role, + full_name: fullName, + rijschool_id: role === 'rijschool' ? rijschoolId : null, + }); + if (error && error.code !== '23505') { + throw new Error(`user_profiles insert failed: ${error.message}`); + } +} + +export async function POST(request: NextRequest) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown'; + if (!rateLimit(`chat-claim:${ip}`, { maxRequests: 20, windowMs: 60_000 })) { + return NextResponse.json({ error: 'Te veel verzoeken.' }, { status: 429 }); + } + + const authHeader = request.headers.get('authorization'); + const jwt = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + if (!jwt) { + return NextResponse.json({ error: 'Niet ingelogd.' }, { status: 401 }); + } + + let body: { token?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Ongeldige request body.' }, { status: 400 }); + } + const token = typeof body.token === 'string' ? body.token.trim() : ''; + + try { + const supabase = getServiceClient(); + + const { data: userData, error: userError } = await supabase.auth.getUser(jwt); + const user = userData?.user; + if (userError || !user?.email) { + return NextResponse.json({ error: 'Sessie ongeldig. Log opnieuw in.' }, { status: 401 }); + } + + const lookup = await lookupRecipientByToken(token); + if (!lookup) { + return NextResponse.json({ error: 'Ongeldige of verlopen link.' }, { status: 404 }); + } + const { recipient, role } = lookup; + + const { data: inquiry } = await supabase + .from('inquiries') + .select('id, leerling_user_id, leerling_email, leerling_name') + .eq('id', recipient.inquiry_id) + .single(); + if (!inquiry) { + return NextResponse.json({ error: 'Aanvraag niet gevonden.' }, { status: 404 }); + } + + const expectedEmail = role === 'rijschool' ? recipient.notified_email : inquiry.leerling_email; + if (!expectedEmail || user.email.toLowerCase() !== expectedEmail.toLowerCase()) { + return NextResponse.json( + { error: 'Dit e-mailadres hoort niet bij deze chat.' }, + { status: 403 }, + ); + } + + if (role === 'rijschool') { + await ensureUserProfile(user.id, 'rijschool', null, recipient.rijschool_id); + + const recipientUpdate: Record = { rijschool_user_id: user.id }; + if (recipient.status === 'pending' || recipient.status === 'app_notified') { + recipientUpdate.status = 'opened'; + } + const { error: updateError } = await supabase + .from('inquiry_recipients') + .update(recipientUpdate) + .eq('id', recipient.id); + if (updateError) { + throw new Error(`inquiry_recipients update failed: ${updateError.message}`); + } + + // Conversatie lazy aanmaken bij eerste claim (schema-afwijking D2). + let { data: conversation } = await supabase + .from('conversations') + .select('id') + .eq('inquiry_recipient_id', recipient.id) + .maybeSingle(); + + if (!conversation) { + const { data: created, error: convError } = await supabase + .from('conversations') + .insert({ + inquiry_recipient_id: recipient.id, + rijschool_user_id: user.id, + rijschool_id: recipient.rijschool_id, + leerling_user_id: inquiry.leerling_user_id, + }) + .select('id') + .single(); + if (convError) { + // 23505 = race met een parallelle claim; dan bestaat hij inmiddels. + if (convError.code !== '23505') { + throw new Error(`conversations insert failed: ${convError.message}`); + } + const { data: raced } = await supabase + .from('conversations') + .select('id') + .eq('inquiry_recipient_id', recipient.id) + .maybeSingle(); + conversation = raced; + } else { + conversation = created; + } + } + + return NextResponse.json({ + conversation_id: conversation?.id ?? null, + role, + status: recipientUpdate.status ?? recipient.status, + }); + } + + // role === 'leerling' + await ensureUserProfile(user.id, 'leerling', inquiry.leerling_name, null); + + if (!inquiry.leerling_user_id) { + const { error: linkError } = await supabase + .from('inquiries') + .update({ leerling_user_id: user.id }) + .eq('id', inquiry.id) + .is('leerling_user_id', null); + if (linkError) { + throw new Error(`inquiries link failed: ${linkError.message}`); + } + } else if (inquiry.leerling_user_id !== user.id) { + // Zelfde e-mailadres kan niet bij twee verschillende auth-users horen, + // maar wees defensief. + return NextResponse.json({ error: 'Deze aanvraag hoort bij een ander account.' }, { status: 403 }); + } + + // Backfill: álle conversaties van deze inquiry (ook die van andere + // rijscholen) aan dit leerling-account hangen, zodat web en app dezelfde + // gesprekken tonen (account-continuïteit, Epic #35). + const { data: recipientIds } = await supabase + .from('inquiry_recipients') + .select('id') + .eq('inquiry_id', inquiry.id); + if (recipientIds && recipientIds.length > 0) { + const { error: backfillError } = await supabase + .from('conversations') + .update({ leerling_user_id: user.id }) + .in('inquiry_recipient_id', recipientIds.map((r) => r.id)) + .is('leerling_user_id', null); + if (backfillError) { + throw new Error(`conversations backfill failed: ${backfillError.message}`); + } + } + + const { data: conversation } = await supabase + .from('conversations') + .select('id') + .eq('inquiry_recipient_id', recipient.id) + .maybeSingle(); + + // Geen conversatie = rijschool heeft nog nooit geclaimd; de UI toont dan + // een wachtstand (kan alleen via een verouderde link gebeuren — reply- + // mails bestaan pas nádat de rijschool een bericht stuurde). + return NextResponse.json({ + conversation_id: conversation?.id ?? null, + role, + status: recipient.status, + }); + } catch (error) { + console.error('chat/claim error:', error); + return NextResponse.json({ error: 'Er ging iets mis.' }, { status: 500 }); + } +} diff --git a/app/api/chat/resolve/route.ts b/app/api/chat/resolve/route.ts new file mode 100644 index 0000000..63f9afa --- /dev/null +++ b/app/api/chat/resolve/route.ts @@ -0,0 +1,96 @@ +// Token-resolutie voor de web-chat gateway (/chat/{token}, issue ribba.app#42). +// Geeft een geanonimiseerde preview terug: geen e-mail/telefoon van de +// leerling vóór accept, alleen een masked hint van het te verifiëren adres. + +import { NextRequest, NextResponse } from 'next/server'; +import { rateLimit } from '@/lib/rate-limit'; +import { getServiceClient, getCbrRijscholen, lookupRecipientByToken } from '@/lib/marketplace-db'; +import { anonymizedFirstName } from '@/lib/marketplace-emails'; + +function maskEmail(email: string): string { + const [local, domain] = email.split('@'); + if (!domain) return '***'; + return `${local.slice(0, 1)}${'*'.repeat(Math.max(local.length - 1, 2))}@${domain}`; +} + +export async function POST(request: NextRequest) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown'; + if (!rateLimit(`chat-resolve:${ip}`, { maxRequests: 30, windowMs: 60_000 })) { + return NextResponse.json({ error: 'Te veel verzoeken.' }, { status: 429 }); + } + + let body: { token?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Ongeldige request body.' }, { status: 400 }); + } + const token = typeof body.token === 'string' ? body.token.trim() : ''; + + try { + const lookup = await lookupRecipientByToken(token); + if (!lookup) { + return NextResponse.json({ error: 'Ongeldige of verlopen link.' }, { status: 404 }); + } + const { recipient, role } = lookup; + const supabase = getServiceClient(); + + const { data: inquiry } = await supabase + .from('inquiries') + .select('id, leerling_user_id, leerling_email, leerling_phone, leerling_name, rijbewijs_categorie, schakeling, gewenste_startdatum, bericht, created_at') + .eq('id', recipient.inquiry_id) + .single(); + if (!inquiry) { + return NextResponse.json({ error: 'Aanvraag niet gevonden.' }, { status: 404 }); + } + + const [school] = await getCbrRijscholen([recipient.rijschool_id]); + + const { data: conversation } = await supabase + .from('conversations') + .select('id') + .eq('inquiry_recipient_id', recipient.id) + .maybeSingle(); + + const expectedEmail = role === 'rijschool' ? recipient.notified_email : inquiry.leerling_email; + if (!expectedEmail) { + // Rijschool-token terwijl er nooit een outreach-mail is verstuurd: + // hoort niet voor te komen (de link staat alleen in die mail). + return NextResponse.json({ error: 'Deze link is nog niet actief.' }, { status: 409 }); + } + + const claimed = role === 'rijschool' + ? recipient.rijschool_user_id !== null + : inquiry.leerling_user_id !== null; + + return NextResponse.json({ + role, + status: recipient.status, + claimed, + conversation_id: conversation?.id ?? null, + expected_email_masked: maskEmail(expectedEmail), + counterpart_name: role === 'rijschool' + ? anonymizedFirstName(inquiry.leerling_name) + : (school?.name ?? 'Rijschool'), + inquiry_preview: { + voornaam: anonymizedFirstName(inquiry.leerling_name), + rijbewijs_categorie: inquiry.rijbewijs_categorie, + schakeling: inquiry.schakeling, + gewenste_startdatum: inquiry.gewenste_startdatum, + bericht: inquiry.bericht, + created_at: inquiry.created_at, + }, + // Contact-reveal pas na accept (ribbaPro#140) en alleen voor de rijschool. + contact: role === 'rijschool' && recipient.status === 'accepted' + ? { + name: inquiry.leerling_name, + email: inquiry.leerling_email, + phone: inquiry.leerling_phone, + } + : null, + }); + } catch (error) { + console.error('chat/resolve error:', error); + return NextResponse.json({ error: 'Er ging iets mis.' }, { status: 500 }); + } +} diff --git a/app/api/cron/chat-notifications/route.ts b/app/api/cron/chat-notifications/route.ts new file mode 100644 index 0000000..687afcf --- /dev/null +++ b/app/api/cron/chat-notifications/route.ts @@ -0,0 +1,213 @@ +// Reply-notificatie e-mails voor de web-chat (issue ribba.app#44). +// Draait elke 5 minuten (vercel.json). Per conversatie-kant: is er een nieuw +// counterpart-bericht sinds de laatste notificatie én is dat ≥2 min oud +// (settle-delay tegen mail-per-toetsaanslag) én is de laatste mail ≥15 min +// geleden → één gebundelde mail. Ontvangers met actieve push (app) of met +// opt-out krijgen géén mail; een nog niet geclaimde leerling juist altijd — +// dat is de funnel-stap die de leerling de chat in brengt. +// +// Auth: Vercel Cron stuurt automatisch `Authorization: Bearer ${CRON_SECRET}`. + +import { NextRequest, NextResponse } from 'next/server'; +import { getServiceClient, getCbrRijscholen } from '@/lib/marketplace-db'; +import { sendReplyNotificationMail, anonymizedFirstName } from '@/lib/marketplace-emails'; +import type { ChatRole, MessageRow, UserProfileRow } from '@/lib/marketplace-types'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; + +const SETTLE_DELAY_MS = 2 * 60 * 1000; // bericht moet ≥2 min oud zijn +const MIN_MAIL_GAP_MS = 15 * 60 * 1000; // max 1 mail per kant per 15 min + +interface ConversationJoin { + id: string; + leerling_user_id: string | null; + rijschool_user_id: string; + rijschool_id: number; + last_message_at: string | null; + leerling_last_notified_at: string | null; + rijschool_last_notified_at: string | null; + inquiry_recipients: { + id: string; + notified_email: string | null; + rijschool_chat_token: string; + leerling_chat_token: string; + leerling_email_optout_at: string | null; + rijschool_email_optout_at: string | null; + inquiries: { + leerling_email: string; + leerling_name: string; + }; + }; +} + +export async function GET(request: NextRequest) { + const auth = request.headers.get('authorization'); + if (!process.env.CRON_SECRET || auth !== `Bearer ${process.env.CRON_SECRET}`) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const supabase = getServiceClient(); + const now = Date.now(); + const settleCutoff = new Date(now - SETTLE_DELAY_MS).toISOString(); + + // Kandidaten: conversaties met recente activiteit (ruime window; de echte + // filtering per kant gebeurt hieronder in JS — PostgREST kan geen twee + // kolommen met elkaar vergelijken). + const { data: candidates, error } = await supabase + .from('conversations') + .select(` + id, leerling_user_id, rijschool_user_id, rijschool_id, + last_message_at, leerling_last_notified_at, rijschool_last_notified_at, + inquiry_recipients ( + id, notified_email, rijschool_chat_token, leerling_chat_token, + leerling_email_optout_at, rijschool_email_optout_at, + inquiries ( leerling_email, leerling_name ) + ) + `) + .not('last_message_at', 'is', null) + .lte('last_message_at', settleCutoff) + .gte('last_message_at', new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString()); + + if (error) { + console.error('chat-notifications: conversations query failed', error); + return NextResponse.json({ error: 'query failed' }, { status: 500 }); + } + + const conversations = (candidates ?? []) as unknown as ConversationJoin[]; + + // Profielen in bulk ophalen (push-token + e-mailvoorkeur). + const userIds = [ + ...new Set( + conversations.flatMap((c) => [c.leerling_user_id, c.rijschool_user_id]).filter((id): id is string => !!id), + ), + ]; + const profiles = new Map>(); + if (userIds.length > 0) { + const { data: profileRows } = await supabase + .from('user_profiles') + .select('user_id, expo_push_token, email_notifications') + .in('user_id', userIds); + for (const p of profileRows ?? []) { + profiles.set(p.user_id, p); + } + } + + // Rijschoolnamen in bulk (afzendernaam voor leerling-mails). + const schoolIds = [...new Set(conversations.map((c) => c.rijschool_id))]; + const schools = schoolIds.length > 0 ? await getCbrRijscholen(schoolIds) : []; + const schoolById = new Map(schools.map((s) => [s.id, s])); + + let sent = 0; + let skipped = 0; + let failed = 0; + + for (const conv of conversations) { + const recipientRow = conv.inquiry_recipients; + if (!recipientRow?.inquiries) { + skipped++; + continue; + } + + for (const side of ['leerling', 'rijschool'] as ChatRole[]) { + try { + const lastNotified = side === 'leerling' ? conv.leerling_last_notified_at : conv.rijschool_last_notified_at; + + // Throttle: max 1 mail per kant per MIN_MAIL_GAP_MS. + if (lastNotified && now - new Date(lastNotified).getTime() < MIN_MAIL_GAP_MS) { + skipped++; + continue; + } + // Niets nieuws sinds de vorige notificatie. + if (lastNotified && conv.last_message_at && new Date(conv.last_message_at) <= new Date(lastNotified)) { + skipped++; + continue; + } + + const counterpartRole: ChatRole = side === 'leerling' ? 'rijschool' : 'leerling'; + let unreadQuery = supabase + .from('messages') + .select('body, created_at') + .eq('conversation_id', conv.id) + .eq('sender_role', counterpartRole) + .is('read_at', null) + .lte('created_at', settleCutoff) + .order('created_at', { ascending: false }); + if (lastNotified) { + unreadQuery = unreadQuery.gt('created_at', lastNotified); + } + const { data: unread } = await unreadQuery; + const unreadMessages = (unread ?? []) as Pick[]; + if (unreadMessages.length === 0) { + skipped++; + continue; + } + + // Notify-regel per kant. + const sideUserId = side === 'leerling' ? conv.leerling_user_id : conv.rijschool_user_id; + const optedOut = side === 'leerling' + ? recipientRow.leerling_email_optout_at !== null + : recipientRow.rijschool_email_optout_at !== null; + if (optedOut) { + skipped++; + continue; + } + if (sideUserId) { + const profile = profiles.get(sideUserId); + if (profile) { + // Actieve push in de app → geen dubbele e-mail (ribbaPro#144 dekt push). + if (profile.expo_push_token) { + skipped++; + continue; + } + if (!profile.email_notifications) { + skipped++; + continue; + } + } + } + // Ongeclaimde leerling (geen user_id): altijd mailen — dit is de stap + // die de leerling voor het eerst de web-chat in brengt. + + const to = side === 'leerling' + ? recipientRow.inquiries.leerling_email + : recipientRow.notified_email; + if (!to) { + skipped++; + continue; + } + + const senderName = side === 'leerling' + ? (schoolById.get(conv.rijschool_id)?.name ?? 'de rijschool') + : anonymizedFirstName(recipientRow.inquiries.leerling_name); + + const ok = await sendReplyNotificationMail({ + to, + senderName, + messageCount: unreadMessages.length, + preview: unreadMessages[0].body, + chatToken: side === 'leerling' ? recipientRow.leerling_chat_token : recipientRow.rijschool_chat_token, + }); + + if (ok) { + await supabase + .from('conversations') + .update( + side === 'leerling' + ? { leerling_last_notified_at: new Date().toISOString() } + : { rijschool_last_notified_at: new Date().toISOString() }, + ) + .eq('id', conv.id); + sent++; + } else { + failed++; + } + } catch (err) { + console.error('chat-notifications: side failed', conv.id, side, err); + failed++; + } + } + } + + return NextResponse.json({ sent, skipped, failed, candidates: conversations.length }); +} diff --git a/app/api/inquiry-submit/route.ts b/app/api/inquiry-submit/route.ts new file mode 100644 index 0000000..4efacf3 --- /dev/null +++ b/app/api/inquiry-submit/route.ts @@ -0,0 +1,209 @@ +// Inquiry-intake vanaf de vergelijkingssite (ribba.app, statisch — POST +// cross-origin hierheen). Maakt 1 inquiries-rij + N inquiry_recipients aan en +// stuurt outreach-mails naar de geselecteerde rijscholen (na de response, +// via after()). Issue ribba.app#33. + +import { NextRequest, NextResponse, after } from 'next/server'; +import { rateLimit } from '@/lib/rate-limit'; +import { isValidEmail, isValidInternationalPhone } from '@/utils/validation'; +import { corsHeaders, corsPreflight } from '@/lib/cors'; +import { getServiceClient, getCbrRijscholen } from '@/lib/marketplace-db'; +import { sendRijschoolOutreachMail } from '@/lib/marketplace-emails'; + +export const maxDuration = 60; + +const RIJBEWIJS_CATEGORIEEN = ['B', 'AM', 'A', 'BE', 'C', 'CE', 'D', 'DE', 'T']; +const SCHAKELINGEN = ['handgeschakeld', 'automaat', 'beide']; +const MAX_RECIPIENTS = 10; + +export async function OPTIONS(request: NextRequest) { + return corsPreflight(request.headers.get('origin')); +} + +export async function POST(request: NextRequest) { + const headers = corsHeaders(request.headers.get('origin')); + + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown'; + if (!rateLimit(`inquiry:${ip}`, { maxRequests: 5, windowMs: 3_600_000 })) { + return NextResponse.json( + { error: 'Te veel aanvragen. Probeer het over een uur opnieuw.' }, + { status: 429, headers }, + ); + } + + let body: Record; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Ongeldige request body.' }, { status: 400, headers }); + } + + // Honeypot: verborgen veld dat de ContactForm meestuurt. Gevuld → bot. + // Antwoord identiek aan een echte submit zodat bots niets leren. + if (typeof body.website === 'string' && body.website.trim() !== '') { + const fakeCount = Array.isArray(body.rijschool_ids) + ? Math.min(body.rijschool_ids.length, MAX_RECIPIENTS) + : 1; + return NextResponse.json( + { inquiry_id: crypto.randomUUID(), recipients_count: fakeCount }, + { status: 201, headers }, + ); + } + + const leerlingName = typeof body.leerling_name === 'string' ? body.leerling_name.trim() : ''; + const leerlingEmail = typeof body.leerling_email === 'string' ? body.leerling_email.trim().toLowerCase() : ''; + const leerlingPhone = typeof body.leerling_phone === 'string' && body.leerling_phone.trim() !== '' + ? body.leerling_phone.trim() + : null; + const categorie = body.rijbewijs_categorie; + const schakeling = body.schakeling ?? null; + const startdatum = typeof body.gewenste_startdatum === 'string' && body.gewenste_startdatum !== '' + ? body.gewenste_startdatum + : null; + const opleidingsvoorkeur = typeof body.opleidingsvoorkeur === 'string' && body.opleidingsvoorkeur.trim() !== '' + ? body.opleidingsvoorkeur.trim().slice(0, 500) + : null; + const bericht = typeof body.bericht === 'string' && body.bericht.trim() !== '' + ? body.bericht.trim().slice(0, 2000) + : null; + const sourcePage = typeof body.source_page === 'string' ? body.source_page.slice(0, 500) : null; + + if (!leerlingName || leerlingName.length > 120) { + return NextResponse.json({ error: 'Naam is verplicht.' }, { status: 400, headers }); + } + if (!isValidEmail(leerlingEmail)) { + return NextResponse.json({ error: 'Ongeldig e-mailadres.' }, { status: 400, headers }); + } + if (leerlingPhone && !isValidInternationalPhone(leerlingPhone)) { + return NextResponse.json({ error: 'Ongeldig telefoonnummer.' }, { status: 400, headers }); + } + if (typeof categorie !== 'string' || !RIJBEWIJS_CATEGORIEEN.includes(categorie)) { + return NextResponse.json({ error: 'Ongeldige rijbewijscategorie.' }, { status: 400, headers }); + } + if (schakeling !== null && (typeof schakeling !== 'string' || !SCHAKELINGEN.includes(schakeling))) { + return NextResponse.json({ error: 'Ongeldige schakeling.' }, { status: 400, headers }); + } + if (startdatum !== null && (!/^\d{4}-\d{2}-\d{2}$/.test(startdatum) || isNaN(Date.parse(startdatum)))) { + return NextResponse.json({ error: 'Ongeldige startdatum.' }, { status: 400, headers }); + } + if (body.toestemming !== true) { + return NextResponse.json( + { error: 'Akkoord met het doorsturen van je aanvraag is verplicht.' }, + { status: 400, headers }, + ); + } + + const rawIds = Array.isArray(body.rijschool_ids) ? body.rijschool_ids : null; + if (!rawIds || rawIds.length === 0) { + return NextResponse.json({ error: 'Selecteer minimaal één rijschool.' }, { status: 400, headers }); + } + const rijschoolIds = [...new Set(rawIds)].filter( + (id): id is number => typeof id === 'number' && Number.isInteger(id) && id > 0, + ); + if (rijschoolIds.length === 0 || rijschoolIds.length !== rawIds.length || rijschoolIds.length > MAX_RECIPIENTS) { + return NextResponse.json( + { error: `Ongeldige rijschool-selectie (1 t/m ${MAX_RECIPIENTS} rijscholen).` }, + { status: 400, headers }, + ); + } + + try { + const schools = await getCbrRijscholen(rijschoolIds); + if (schools.length !== rijschoolIds.length) { + return NextResponse.json({ error: 'Eén of meer rijscholen zijn onbekend.' }, { status: 400, headers }); + } + + const supabase = getServiceClient(); + + const { data: inquiry, error: inquiryError } = await supabase + .from('inquiries') + .insert({ + leerling_name: leerlingName, + leerling_email: leerlingEmail, + leerling_phone: leerlingPhone, + rijbewijs_categorie: categorie, + schakeling, + gewenste_startdatum: startdatum, + opleidingsvoorkeur, + bericht, + source_page: sourcePage, + }) + .select('id') + .single(); + + if (inquiryError || !inquiry) { + console.error('inquiry-submit: inquiry insert failed', inquiryError); + return NextResponse.json( + { error: 'Er ging iets mis bij het opslaan. Probeer het opnieuw.' }, + { status: 500, headers }, + ); + } + + const { data: recipients, error: recipientsError } = await supabase + .from('inquiry_recipients') + .insert(rijschoolIds.map((rijschoolId) => ({ + inquiry_id: inquiry.id, + rijschool_id: rijschoolId, + }))) + .select('id, rijschool_id, rijschool_chat_token'); + + if (recipientsError || !recipients || recipients.length === 0) { + console.error('inquiry-submit: recipients insert failed', recipientsError); + // Compenserende delete — PostgREST kent geen cross-statement transacties. + await supabase.from('inquiries').delete().eq('id', inquiry.id); + return NextResponse.json( + { error: 'Er ging iets mis bij het opslaan. Probeer het opnieuw.' }, + { status: 500, headers }, + ); + } + + // Outreach ná de response: de leerling hoeft niet op 10 Resend-calls te + // wachten. Eén mislukte mail laat de recipient op 'pending' staan + // (zichtbaar in de data; retry is een follow-up van de notificatie-cron). + after(async () => { + const schoolById = new Map(schools.map((s) => [s.id, s])); + for (const recipient of recipients) { + const school = schoolById.get(recipient.rijschool_id); + if (!school?.email) { + console.warn('inquiry-submit: rijschool zonder e-mailadres, outreach overgeslagen', recipient.rijschool_id); + continue; + } + try { + const sent = await sendRijschoolOutreachMail({ + to: school.email, + rijschoolName: school.name, + leerlingFullName: leerlingName, + rijbewijsCategorie: categorie, + schakeling: typeof schakeling === 'string' ? schakeling : null, + gewensteStartdatum: startdatum, + bericht, + chatToken: recipient.rijschool_chat_token, + }); + if (sent) { + await supabase + .from('inquiry_recipients') + .update({ + status: 'app_notified', + notification_email_sent_at: new Date().toISOString(), + notified_email: school.email, + }) + .eq('id', recipient.id); + } + } catch (err) { + console.error('inquiry-submit: outreach failed', recipient.id, err); + } + } + }); + + return NextResponse.json( + { inquiry_id: inquiry.id, recipients_count: recipients.length }, + { status: 201, headers }, + ); + } catch (error) { + console.error('inquiry-submit error:', error); + return NextResponse.json( + { error: 'Er ging iets mis. Probeer het opnieuw.' }, + { status: 500, headers }, + ); + } +} diff --git a/app/api/notifications/opt-out/route.ts b/app/api/notifications/opt-out/route.ts new file mode 100644 index 0000000..352da77 --- /dev/null +++ b/app/api/notifications/opt-out/route.ts @@ -0,0 +1,81 @@ +// Opt-out voor reply-notificatie e-mails (issue ribba.app#44). De chat-token +// uit de mail identificeert de kant (leerling/rijschool). Zet de optout-stempel +// op de inquiry_recipient en — als die kant al een account heeft — +// email_notifications=false op het profiel. + +import { NextRequest, NextResponse } from 'next/server'; +import { rateLimit } from '@/lib/rate-limit'; +import { getServiceClient, lookupRecipientByToken } from '@/lib/marketplace-db'; + +export const dynamic = 'force-dynamic'; + +function page(title: string, body: string): NextResponse { + return new NextResponse( + ` + +${title} — Ribba + +

${title}

${body}

`, + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ); +} + +export async function GET(request: NextRequest) { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown'; + if (!rateLimit(`opt-out:${ip}`, { maxRequests: 10, windowMs: 60_000 })) { + return NextResponse.json({ error: 'Te veel verzoeken.' }, { status: 429 }); + } + + const token = request.nextUrl.searchParams.get('token')?.trim() ?? ''; + + try { + const lookup = await lookupRecipientByToken(token); + if (!lookup) { + return page('Link ongeldig', 'Deze afmeldlink is ongeldig of verlopen.'); + } + const { recipient, role } = lookup; + const supabase = getServiceClient(); + + const { error: stampError } = await supabase + .from('inquiry_recipients') + .update( + role === 'leerling' + ? { leerling_email_optout_at: new Date().toISOString() } + : { rijschool_email_optout_at: new Date().toISOString() }, + ) + .eq('id', recipient.id); + if (stampError) { + throw new Error(`opt-out stamp failed: ${stampError.message}`); + } + + // Kant al geclaimd → ook de profielvoorkeur uitzetten (geldt dan voor + // alle conversaties van dit account). + let sideUserId: string | null = null; + if (role === 'rijschool') { + sideUserId = recipient.rijschool_user_id; + } else { + const { data: inquiry } = await supabase + .from('inquiries') + .select('leerling_user_id') + .eq('id', recipient.inquiry_id) + .single(); + sideUserId = inquiry?.leerling_user_id ?? null; + } + if (sideUserId) { + await supabase + .from('user_profiles') + .update({ email_notifications: false }) + .eq('user_id', sideUserId); + } + + return page( + 'Afgemeld', + 'Je ontvangt geen e-mails meer over nieuwe chatberichten. Je kunt de chat altijd blijven openen via de eerdere links of de Ribba app.', + ); + } catch (error) { + console.error('opt-out error:', error); + return page('Er ging iets mis', 'Probeer het later opnieuw of mail hallo@ribba.app.'); + } +} diff --git a/app/chat/[token]/layout.tsx b/app/chat/[token]/layout.tsx new file mode 100644 index 0000000..c25b8fe --- /dev/null +++ b/app/chat/[token]/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from 'next'; + +const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL || 'https://link.ribba.app'; +// App Store ID van de Ribba app (zelfde app als lib/app-links.ts). +const APPLE_APP_ID = process.env.NEXT_PUBLIC_APPLE_APP_ID || '6757161459'; + +// Smart app banners (issue ribba.app#43) — bewust alleen in deze layout zodat +// planner- en vergelijker-pagina's ze niet tonen: +// - iOS Safari: apple-itunes-app meta met app-argument deep-link naar deze chat +// - Android Chrome: install banner via het chat-specifieke web manifest +export async function generateMetadata( + { params }: { params: Promise<{ token: string }> }, +): Promise { + const { token } = await params; + return { + title: 'Chat — Ribba', + description: 'Beveiligde, geanonimiseerde chat tussen leerling en rijschool via Ribba.', + robots: { index: false, follow: false }, + itunes: { + appId: APPLE_APP_ID, + appArgument: `${BASE_URL}/chat/${token}`, + }, + manifest: '/chat-manifest.webmanifest', + }; +} + +export default function ChatLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/app/chat/[token]/page.tsx b/app/chat/[token]/page.tsx new file mode 100644 index 0000000..4fe3601 --- /dev/null +++ b/app/chat/[token]/page.tsx @@ -0,0 +1,6 @@ +import ChatGateway from '@/components/chat/ChatGateway'; + +export default async function ChatPage({ params }: { params: Promise<{ token: string }> }) { + const { token } = await params; + return ; +} diff --git a/app/globals.css b/app/globals.css index 6f04c74..6bc4a24 100644 --- a/app/globals.css +++ b/app/globals.css @@ -680,3 +680,258 @@ a.text-link:hover { font-weight: 500; text-align: center; } + +/* ─── Web-chat gateway (/chat/[token]) ───────────── */ + +.chat-page { + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + background: #F5F5F4; +} + +.chat-page .spinner { + border-color: rgba(37, 99, 235, 0.25); + border-top-color: #2563EB; + margin: 16px auto; +} + +.chat-center-card { + background: #fff; + border-radius: 20px; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06); + padding: 40px 28px; + margin: auto 16px; + max-width: 440px; + width: calc(100% - 32px); + text-align: center; +} + +.chat-center-card h1 { + font-size: 24px; + margin-top: 20px; +} + +.chat-muted { + color: #57534E; + font-size: 15px; + line-height: 1.6; + margin-bottom: 16px; +} + +.chat-otp-form { + text-align: left; + margin-top: 8px; +} + +.chat-link-button { + background: none; + border: none; + padding: 0; + margin-top: 14px; + color: #2563EB; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-decoration: underline; +} + +.chat-shell { + display: flex; + flex-direction: column; + width: 100%; + max-width: 640px; + height: 100dvh; + background: #fff; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid #E7E5E4; + background: #fff; +} + +.chat-header-info h1 { + font-size: 17px; + margin-bottom: 2px; +} + +.chat-header .pill { + margin-bottom: 0; + flex-shrink: 0; +} + +.chat-contact { + font-size: 13px; + color: #16a34a; + font-weight: 600; +} + +.chat-anon-note { + font-size: 12px; + color: #78716C; +} + +.chat-app-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 10px 16px; + background: #EFF6FF; + border-bottom: 1px solid #DBEAFE; + font-size: 13px; + color: #1E40AF; + font-weight: 600; +} + +.chat-app-badges { + display: flex; + gap: 8px; +} + +.chat-thread { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 6px; + background: #F5F5F4; +} + +.chat-inquiry-card { + background: #F0FDFA; + border: 1px solid #CCFBF1; + border-radius: 14px; + padding: 12px 16px; + margin-bottom: 10px; + font-size: 13px; +} + +.chat-inquiry-title { + font-weight: 700; + color: #134E4A; +} + +.chat-inquiry-detail { + color: #0F766E; + margin-top: 2px; +} + +.chat-inquiry-message { + color: #1C1917; + margin-top: 6px; + font-style: italic; +} + +.chat-day-divider { + text-align: center; + font-size: 12px; + color: #A8A29E; + font-weight: 600; + margin: 12px 0 6px; +} + +.chat-bubble-row { + display: flex; + justify-content: flex-start; +} + +.chat-bubble-row.own { + justify-content: flex-end; +} + +.chat-bubble { + max-width: 78%; + background: #fff; + border-radius: 16px 16px 16px 4px; + padding: 9px 12px 6px; + box-shadow: 0 1px 1px rgba(15, 23, 42, 0.06); + font-size: 15px; + line-height: 1.45; + color: #1C1917; +} + +.chat-bubble p { + white-space: pre-wrap; + word-break: break-word; +} + +.chat-bubble-own { + background: #DBEAFE; + border-radius: 16px 16px 4px 16px; +} + +.chat-bubble-meta { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 4px; + font-size: 11px; + color: #78716C; + margin-top: 3px; +} + +.chat-ticks { + font-size: 11px; + letter-spacing: -1px; + color: #A8A29E; +} + +.chat-ticks.read { + color: #2563EB; +} + +.chat-composer { + padding: 10px 12px calc(10px + env(safe-area-inset-bottom)); + border-top: 1px solid #E7E5E4; + background: #fff; +} + +.chat-composer-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.chat-composer textarea { + flex: 1; + resize: none; + border: 1.5px solid #E7E5E4; + border-radius: 14px; + padding: 11px 14px; + font-size: 15px; + font-family: inherit; + line-height: 1.4; + max-height: 120px; + outline: none; +} + +.chat-composer textarea:focus { + border-color: #2563EB; +} + +.chat-send-button { + width: auto; + margin-top: 0; + padding: 12px 18px; + border-radius: 14px; + flex-shrink: 0; +} + +@media (min-width: 640px) { + .chat-shell { + height: calc(100dvh - 48px); + margin: 24px 0; + border-radius: 20px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); + } +} diff --git a/components/chat/ChatGateway.tsx b/components/chat/ChatGateway.tsx new file mode 100644 index 0000000..2767933 --- /dev/null +++ b/components/chat/ChatGateway.tsx @@ -0,0 +1,211 @@ +'use client'; + +// Web-chat gateway (issue ribba.app#42): vangt de link uit de outreach-/ +// reply-mail op, gate't op e-mailverificatie (Supabase OTP) en plaatst de +// geverifieerde gebruiker in de geanonimiseerde 1-op-1 chat. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createBrowserClient } from '@supabase/ssr'; +import type { Session, SupabaseClient } from '@supabase/supabase-js'; +import RibbaLogo from '@/app/components/RibbaLogo'; +import OtpGate from './OtpGate'; +import ChatThread from './ChatThread'; +import type { ChatRole, InquiryRecipientStatus } from '@/lib/marketplace-types'; + +export interface ResolveInfo { + role: ChatRole; + status: InquiryRecipientStatus; + claimed: boolean; + conversation_id: string | null; + expected_email_masked: string; + counterpart_name: string; + inquiry_preview: { + voornaam: string; + rijbewijs_categorie: string; + schakeling: string | null; + gewenste_startdatum: string | null; + bericht: string | null; + created_at: string; + }; + contact: { name: string; email: string; phone: string | null } | null; +} + +type Phase = + | 'resolving' + | 'invalid' + | 'otp' + | 'claiming' + | 'waiting' // leerling geclaimd, maar rijschool heeft de chat nog niet geopend + | 'chat' + | 'error'; + +let browserClient: SupabaseClient | null = null; + +function getSupabase(): SupabaseClient { + if (!browserClient) { + browserClient = createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); + } + return browserClient; +} + +export default function ChatGateway({ token }: { token: string }) { + const [phase, setPhase] = useState('resolving'); + const [info, setInfo] = useState(null); + const [conversationId, setConversationId] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [mismatchEmail, setMismatchEmail] = useState(null); + const claimInFlight = useRef(false); + + const claim = useCallback(async (session: Session): Promise => { + if (claimInFlight.current) return; + claimInFlight.current = true; + setPhase('claiming'); + try { + const res = await fetch('/api/chat/claim', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.access_token}`, + }, + body: JSON.stringify({ token }), + }); + const data = await res.json().catch(() => ({})); + + if (res.status === 403) { + // Ingelogd met een ander adres dan waar de mail heen ging. + setMismatchEmail(session.user.email ?? null); + setPhase('otp'); + return; + } + if (!res.ok) { + setErrorMsg(data.error ?? 'Er ging iets mis.'); + setPhase('error'); + return; + } + if (data.conversation_id) { + setConversationId(data.conversation_id); + setPhase('chat'); + } else { + setPhase('waiting'); + } + } catch { + setErrorMsg('Kon geen verbinding maken. Probeer het opnieuw.'); + setPhase('error'); + } finally { + claimInFlight.current = false; + } + }, [token]); + + useEffect(() => { + (async () => { + const res = await fetch('/api/chat/resolve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }).catch(() => null); + + if (!res || !res.ok) { + setPhase('invalid'); + return; + } + const data: ResolveInfo = await res.json(); + setInfo(data); + + const supabase = getSupabase(); + const { data: { session } } = await supabase.auth.getSession(); + if (session) { + await claim(session); + } else { + setPhase('otp'); + } + })(); + }, [token, claim]); + + async function handleSwitchAccount() { + await getSupabase().auth.signOut(); + setMismatchEmail(null); + } + + if (phase === 'resolving' || phase === 'claiming') { + return ( +
+
+
+

{phase === 'resolving' ? 'Chat laden…' : 'Chat openen…'}

+
+
+ ); + } + + if (phase === 'invalid' || phase === 'error' || !info) { + return ( +
+
+ +

Deze link werkt niet

+

+ {errorMsg ?? 'De chat-link is ongeldig of verlopen. Gebruik de meest recente link uit je e-mail, of mail ons op '} + {!errorMsg && hallo@ribba.app} +

+
+
+ ); + } + + if (phase === 'otp') { + return ( +
+
+ +

Verifieer je e-mailadres

+

+ Om de chat met {info.counterpart_name} te openen, verifieer je het + e-mailadres waarop je deze uitnodiging ontving ({info.expected_email_masked}). +

+ {mismatchEmail && ( +
+ Je bent ingelogd als {mismatchEmail}, maar deze chat hoort bij een ander adres.{' '} + +
+ )} + { void claim(session); }} + /> +
+
+ ); + } + + if (phase === 'waiting') { + return ( +
+
+ +

Nog geen reactie

+

+ Je aanvraag is verstuurd naar {info.counterpart_name}, maar de rijschool + heeft de chat nog niet geopend. Zodra er een reactie is, krijg je een e-mail. +

+
+
+ ); + } + + return ( + + ); +} diff --git a/components/chat/ChatThread.tsx b/components/chat/ChatThread.tsx new file mode 100644 index 0000000..3730937 --- /dev/null +++ b/components/chat/ChatThread.tsx @@ -0,0 +1,220 @@ +'use client'; + +// Geanonimiseerde 1-op-1 chat in WhatsApp-stijl (issue ribba.app#42): +// bubbles links/rechts, timestamps, read-states. Realtime via Supabase +// Realtime op de gedeelde messages-tabel; RLS bepaalt toegang, dus het +// access token moet via realtime.setAuth() meegegeven worden (ook opnieuw +// bij token-refresh, anders valt de subscription stil). + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'; +import { AppStoreBadge, GooglePlayBadge } from '@/app/components/StoreBadges'; +import MessageComposer from './MessageComposer'; +import type { ChatRole, InquiryRecipientStatus, MessageRow } from '@/lib/marketplace-types'; +import type { ResolveInfo } from './ChatGateway'; + +interface ChatThreadProps { + supabase: SupabaseClient; + conversationId: string; + role: ChatRole; + counterpartName: string; + status: InquiryRecipientStatus; + inquiryPreview: ResolveInfo['inquiry_preview']; + contact: ResolveInfo['contact']; +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString('nl-NL', { hour: '2-digit', minute: '2-digit' }); +} + +function formatDay(iso: string): string { + return new Date(iso).toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +export default function ChatThread({ + supabase, + conversationId, + role, + counterpartName, + status, + inquiryPreview, + contact, +}: ChatThreadProps) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const userIdRef = useRef(null); + const bottomRef = useRef(null); + const counterpartRole: ChatRole = role === 'leerling' ? 'rijschool' : 'leerling'; + + const markRead = useCallback(async () => { + await supabase + .from('messages') + .update({ read_at: new Date().toISOString() }) + .eq('conversation_id', conversationId) + .eq('sender_role', counterpartRole) + .is('read_at', null); + }, [supabase, conversationId, counterpartRole]); + + useEffect(() => { + let channel: RealtimeChannel | null = null; + let cancelled = false; + + (async () => { + const { data: { session } } = await supabase.auth.getSession(); + if (!session || cancelled) return; + userIdRef.current = session.user.id; + supabase.realtime.setAuth(session.access_token); + + const { data, error } = await supabase + .from('messages') + .select('*') + .eq('conversation_id', conversationId) + .order('created_at', { ascending: true }); + + if (cancelled) return; + if (error) { + setLoadError(true); + setLoading(false); + return; + } + setMessages((data as MessageRow[]) ?? []); + setLoading(false); + void markRead(); + + channel = supabase + .channel(`chat-${conversationId}`) + .on( + 'postgres_changes', + { event: 'INSERT', schema: 'public', table: 'messages', filter: `conversation_id=eq.${conversationId}` }, + (payload) => { + const msg = payload.new as MessageRow; + setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg])); + if (msg.sender_role === counterpartRole) void markRead(); + }, + ) + .on( + 'postgres_changes', + { event: 'UPDATE', schema: 'public', table: 'messages', filter: `conversation_id=eq.${conversationId}` }, + (payload) => { + const msg = payload.new as MessageRow; + setMessages((prev) => prev.map((m) => (m.id === msg.id ? { ...m, ...msg } : m))); + }, + ) + .subscribe(); + })(); + + // Realtime-token vernieuwen wanneer de sessie ververst. + const { data: authSub } = supabase.auth.onAuthStateChange((_event, session) => { + if (session) supabase.realtime.setAuth(session.access_token); + }); + + return () => { + cancelled = true; + if (channel) supabase.removeChannel(channel); + authSub.subscription.unsubscribe(); + }; + }, [supabase, conversationId, counterpartRole, markRead]); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages.length]); + + async function handleSend(body: string): Promise { + const userId = userIdRef.current; + if (!userId) return false; + const { data, error } = await supabase + .from('messages') + .insert({ + conversation_id: conversationId, + sender_user_id: userId, + sender_role: role, + body, + }) + .select('*') + .single(); + if (error || !data) return false; + const msg = data as MessageRow; + setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg])); + return true; + } + + return ( +
+
+
+
+

{counterpartName}

+ {contact ? ( +

+ {contact.email}{contact.phone ? ` · ${contact.phone}` : ''} +

+ ) : ( +

+ Geanonimiseerd via Ribba{role === 'rijschool' ? ' — contactgegevens zichtbaar na accepteren in de app' : ''} +

+ )} +
+ + {status === 'accepted' ? 'Geaccepteerd' : 'Aanvraag'} + +
+ +
+ Volg dit gesprek met push-meldingen in de Ribba app +
+ + +
+
+ +
+
+

+ Aanvraag van {inquiryPreview.voornaam} · rijbewijs {inquiryPreview.rijbewijs_categorie} + {inquiryPreview.schakeling ? ` · ${inquiryPreview.schakeling}` : ''} +

+ {inquiryPreview.gewenste_startdatum && ( +

Gewenste start: {formatDay(inquiryPreview.gewenste_startdatum)}

+ )} + {inquiryPreview.bericht && ( +

“{inquiryPreview.bericht}”

+ )} +
+ + {loading &&
} + {loadError && ( +
Berichten laden mislukt. Ververs de pagina.
+ )} + + {messages.map((msg, i) => { + const own = msg.sender_role === role; + const day = formatDay(msg.created_at); + const showDay = i === 0 || day !== formatDay(messages[i - 1].created_at); + return ( +
+ {showDay &&
{day}
} +
+
+

{msg.body}

+ + {formatTime(msg.created_at)} + {own && ( + + {msg.read_at ? '✓✓' : '✓'} + + )} + +
+
+
+ ); + })} +
+
+ + +
+
+ ); +} diff --git a/components/chat/MessageComposer.tsx b/components/chat/MessageComposer.tsx new file mode 100644 index 0000000..d22f22a --- /dev/null +++ b/components/chat/MessageComposer.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { FormEvent, useState } from 'react'; + +interface MessageComposerProps { + onSend: (body: string) => Promise; +} + +export default function MessageComposer({ onSend }: MessageComposerProps) { + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + const body = value.trim(); + if (!body || busy) return; + setBusy(true); + setFailed(false); + const ok = await onSend(body); + setBusy(false); + if (ok) { + setValue(''); + } else { + // Bericht blijft in het veld staan — geen verlies van getypte tekst. + setFailed(true); + } + } + + return ( +
+ {failed && ( +
Versturen mislukt. Probeer het opnieuw.
+ )} +
+