diff --git a/.gitignore b/.gitignore index 5ef6a52..cd76776 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Supabase CLI runtime state +supabase/.temp/ diff --git a/app/api/cron/chat-notifications/route.ts b/app/api/cron/chat-notifications/route.ts new file mode 100644 index 0000000..0871d1a --- /dev/null +++ b/app/api/cron/chat-notifications/route.ts @@ -0,0 +1,357 @@ +// Reply-notificatie e-mails voor de web-chat (issue ribba.app#44). +// Draait elke 5 minuten. Getriggerd door Supabase pg_cron (job +// 'chat-notifications-5min'), NIET door Vercel Cron — het Vercel-plan staat +// geen sub-dagelijkse crons toe (3e cron + */5 wordt geweigerd, deploy faalt). +// pg_net doet elke 5 min een GET met `Authorization: Bearer ` +// (secret in Supabase Vault). 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: dezelfde CRON_SECRET-bearer-check als de Vercel-crons. + +import { NextRequest, NextResponse } from 'next/server'; +import { getServiceClient, getCbrRijscholen } from '@/lib/marketplace-db'; +import { sendReplyNotificationMail, sendRijschoolOutreachMail, anonymizedFirstName } from '@/lib/marketplace-emails'; +import type { ChatRole, MessageRow } 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 +const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // rolling chat-token levensduur + +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; + inquiry_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(); + const windowStart = new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(); + + // Retry-sweep: recipients die na een mislukte initiële outreach op 'pending' + // bleven, opnieuw mailen (max 24u oud). Zonder dit hoort de rijschool nooit + // van de aanvraag — er is geen conversation, dus de notificatie-loop hieronder + // ziet ze niet. + const retry = await retryPendingOutreach(supabase, now); + + // Kandidaten via RPC: alleen conversaties waar minstens één kant achterloopt + // op zijn laatste notificatie (kolom-vergelijking die PostgREST niet kan), + // zodat de cap niet vol loopt met al-genotificeerde conversaties. + const { data: idRows, error: idError } = await supabase.rpc('list_notifiable_conversation_ids', { + p_window_start: windowStart, + p_settle_cutoff: settleCutoff, + p_limit: 200, + }); + if (idError) { + console.error('chat-notifications: notifiable-ids rpc failed', idError); + return NextResponse.json({ error: 'query failed', retry }, { status: 500 }); + } + const notifiableIds = (idRows ?? []).map((r: { conversation_id: string }) => r.conversation_id); + + if (notifiableIds.length === 0) { + return NextResponse.json({ sent: 0, skipped: 0, failed: 0, candidates: 0, retry }); + } + + 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, inquiry_id, notified_email, rijschool_chat_token, leerling_chat_token, + leerling_email_optout_at, rijschool_email_optout_at, + inquiries ( leerling_email, leerling_name ) + ) + `) + .in('id', notifiableIds); + + if (error) { + console.error('chat-notifications: conversations query failed', error); + return NextResponse.json({ error: 'query failed', retry }, { status: 500 }); + } + + const conversations = (candidates ?? []) as unknown as ConversationJoin[]; + + // E-mailvoorkeuren + push-status in bulk. Push-status komt uit de bestaande + // multi-device `push_tokens`-tabel die de app onderhoudt (SSoT) — wie daar + // een device heeft, krijgt push via ribbaPro#144 en dus géén e-mail. + const userIds = [ + ...new Set( + conversations.flatMap((c) => [c.leerling_user_id, c.rijschool_user_id]).filter((id): id is string => !!id), + ), + ]; + const emailPrefs = new Map(); + const usersWithPush = new Set(); + if (userIds.length > 0) { + const { data: profileRows } = await supabase + .from('marketplace_profiles') + .select('user_id, email_notifications') + .in('user_id', userIds); + for (const p of profileRows ?? []) { + emailPrefs.set(p.user_id, p.email_notifications); + } + + const { data: pushRows, error: pushError } = await supabase + .from('push_tokens') + .select('user_id') + .eq('is_active', true) + .in('user_id', userIds); + if (pushError) { + // Tabel(naam) niet beschikbaar → conservatief: niemand als push-gedekt + // beschouwen (liever een dubbele notificatie dan geen enkele). + console.warn('chat-notifications: push_tokens lookup failed', pushError.message); + } + for (const p of pushRows ?? []) { + usersWithPush.add(p.user_id); + } + } + + // 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; + } + + // Goedkope filters éérst — de messages-query alleen voor kanten die + // überhaupt gemaild mogen worden. + 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) { + // Actieve push in de app → geen dubbele e-mail (ribbaPro#144 dekt push). + if (usersWithPush.has(sideUserId)) { + skipped++; + continue; + } + if (emailPrefs.get(sideUserId) === false) { + 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 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; + } + + const senderName = side === 'leerling' + ? (schoolById.get(conv.rijschool_id)?.name ?? 'de rijschool') + : anonymizedFirstName(recipientRow.inquiries.leerling_name); + + // Stamp de throttle VÓÓR de send: als de stamp faalt sturen we niet + // (anders zou een gelukte send + gefaalde stamp elke 5 min dezelfde + // mail opnieuw versturen — een duplicate-storm). Bij een gefaalde send + // draaien we de stamp terug zodat de volgende run het opnieuw probeert. + const stampCol = side === 'leerling' ? 'leerling_last_notified_at' : 'rijschool_last_notified_at'; + const { error: stampErr } = await supabase + .from('conversations') + .update({ [stampCol]: new Date().toISOString() }) + .eq('id', conv.id); + if (stampErr) { + console.error('chat-notifications: throttle-stamp failed, send overgeslagen', conv.id, side, stampErr); + failed++; + continue; + } + + const ok = await sendReplyNotificationMail({ + to, + senderName, + messageCount: unreadMessages.length, + preview: unreadMessages[0].body, + chatToken: side === 'leerling' ? recipientRow.leerling_chat_token : recipientRow.rijschool_chat_token, + appPath: side === 'leerling' + ? `/i/${recipientRow.inquiry_id}` + : `/r/${recipientRow.id}`, + }); + + if (ok) { + // Rolling token-expiry: de zojuist gemailde link moet 30 dagen werken. + await supabase + .from('inquiry_recipients') + .update({ chat_tokens_expire_at: new Date(now + TOKEN_TTL_MS).toISOString() }) + .eq('id', recipientRow.id); + sent++; + } else { + // Send mislukt → stamp terugdraaien zodat de volgende run retryt. + await supabase + .from('conversations') + .update({ [stampCol]: lastNotified }) + .eq('id', conv.id); + failed++; + } + } catch (err) { + console.error('chat-notifications: side failed', conv.id, side, err); + failed++; + } + } + } + + return NextResponse.json({ sent, skipped, failed, candidates: conversations.length, retry }); +} + +// Retry-sweep voor recipients die na een mislukte initiële outreach op 'pending' +// bleven (issue: outreach in inquiry-submit is fire-and-forget zonder retry). +// Bounded op de laatste 24u; no-email-scholen worden overgeslagen (die kunnen +// sowieso nooit reageren). +interface PendingRecipient { + id: string; + rijschool_id: number; + rijschool_chat_token: string; + inquiry_id: string; + inquiries: { + leerling_name: string; + rijbewijs_categorie: string; + schakeling: string | null; + gewenste_startdatum: string | null; + bericht: string | null; + } | null; +} + +async function retryPendingOutreach( + supabase: ReturnType, + now: number, +): Promise<{ sent: number; failed: number }> { + const dayAgo = new Date(now - 24 * 60 * 60 * 1000).toISOString(); + const { data, error } = await supabase + .from('inquiry_recipients') + .select(` + id, rijschool_id, rijschool_chat_token, inquiry_id, + inquiries ( leerling_name, rijbewijs_categorie, schakeling, gewenste_startdatum, bericht ) + `) + .eq('status', 'pending') + .is('notification_email_sent_at', null) + .gte('created_at', dayAgo) + .limit(100); + + if (error || !data || data.length === 0) { + if (error) console.error('chat-notifications: retry sweep query failed', error); + return { sent: 0, failed: 0 }; + } + + const rows = data as unknown as PendingRecipient[]; + const schoolIds = [...new Set(rows.map((r) => r.rijschool_id))]; + const schools = await getCbrRijscholen(schoolIds); + const schoolById = new Map(schools.map((s) => [s.id, s])); + + let sent = 0; + let failed = 0; + for (const row of rows) { + const school = schoolById.get(row.rijschool_id); + // Geen e-mail → nooit contacteerbaar; niet blijven proberen (24u-bound stopt het). + if (!school?.email || !row.inquiries) continue; + try { + const ok = await sendRijschoolOutreachMail({ + to: school.email, + rijschoolName: school.name, + leerlingFullName: row.inquiries.leerling_name, + rijbewijsCategorie: row.inquiries.rijbewijs_categorie, + schakeling: row.inquiries.schakeling, + gewensteStartdatum: row.inquiries.gewenste_startdatum, + bericht: row.inquiries.bericht, + chatToken: row.rijschool_chat_token, + recipientId: row.id, + }); + if (ok) { + await supabase + .from('inquiry_recipients') + .update({ + status: 'app_notified', + notification_email_sent_at: new Date().toISOString(), + notified_email: school.email, + }) + .eq('id', row.id); + sent++; + } else { + failed++; + } + } catch (err) { + console.error('chat-notifications: retry outreach failed', row.id, err); + failed++; + } + } + return { sent, failed }; +} diff --git a/app/api/inquiry-submit/route.ts b/app/api/inquiry-submit/route.ts new file mode 100644 index 0000000..ce98266 --- /dev/null +++ b/app/api/inquiry-submit/route.ts @@ -0,0 +1,230 @@ +// 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, sendLeerlingBevestigingMail } 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; + // Mag client-side gesynthetiseerd zijn: de ContactForm vertaalt zsm/+1m/+3m + // naar een concrete datum en "later" naar null. + const startdatum = typeof body.gewenste_startdatum === 'string' && body.gewenste_startdatum !== '' + ? body.gewenste_startdatum + : null; + const marketingOptin = body.marketing_optin === true; + 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(); + + // Intake + 24u-dedupe transactioneel via RPC: een advisory lock op het + // e-mailadres voorkomt de TOCTOU-race waarbij twee gelijktijdige submits + // (dubbelklik/tabs) beide de dedupe passeren en dubbele outreach sturen. + const { data: rpcResult, error: rpcError } = await supabase.rpc('submit_inquiry', { + p_leerling: { + leerling_name: leerlingName, + leerling_email: leerlingEmail, + leerling_phone: leerlingPhone, + rijbewijs_categorie: categorie, + schakeling, + gewenste_startdatum: startdatum, + opleidingsvoorkeur, + bericht, + source_page: sourcePage, + marketing_optin: marketingOptin, + }, + p_rijschool_ids: rijschoolIds, + }); + + if (rpcError || !rpcResult) { + console.error('inquiry-submit: submit_inquiry rpc failed', rpcError); + return NextResponse.json( + { error: 'Er ging iets mis bij het opslaan. Probeer het opnieuw.' }, + { status: 500, headers }, + ); + } + + const inquiryId: string | null = rpcResult.inquiry_id; + const recipients: Array<{ id: string; rijschool_id: number; rijschool_chat_token: string }> = + rpcResult.recipients ?? []; + + if (!inquiryId || recipients.length === 0) { + return NextResponse.json( + { error: 'Je hebt deze rijscholen de afgelopen 24 uur al een aanvraag gestuurd.' }, + { status: 409, headers }, + ); + } + + // Outreach + leerling-bevestiging ná de response: de leerling hoeft niet + // op 10+ Resend-calls te wachten. Eén mislukte mail laat de recipient op + // 'pending' staan; de notificatie-cron sweept die en probeert opnieuw. + after(async () => { + const schoolById = new Map(schools.map((s) => [s.id, s])); + + // Bevestigingsmail naar de leerling: verwachtingen zetten + het eerste + // contactmoment (warmt de mailbox op vóór de reply-notificaties). + // Alleen scholen mét e-mailadres beloven: no-email-scholen worden nooit + // gecontacteerd en kunnen structureel niet reageren. + try { + const emailableNames = recipients + .map((r) => schoolById.get(r.rijschool_id)) + .filter((s): s is NonNullable => !!s?.email) + .map((s) => s.name); + if (emailableNames.length > 0) { + await sendLeerlingBevestigingMail({ + to: leerlingEmail, + leerlingFullName: leerlingName, + schoolNames: emailableNames, + }); + } + } catch (err) { + console.error('inquiry-submit: leerling-bevestiging failed', err); + } + + 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, + recipientId: recipient.id, + }); + 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: inquiryId, 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..8eebc64 --- /dev/null +++ b/app/api/notifications/opt-out/route.ts @@ -0,0 +1,118 @@ +// Opt-out voor reply-notificatie e-mails (issue ribba.app#44). De chat-token +// uit de mail identificeert de kant (leerling/rijschool). +// +// GET valideert alleen en toont een bevestigingsknop; de daadwerkelijke +// mutatie zit in POST. Mail-scanners en link-prefetchers GET'en elke link in +// een mail — die mogen niemand ongevraagd afmelden. + +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, extraHtml = ''): NextResponse { + return new NextResponse( + ` + +${title} — Ribba + +

${title}

${body}

${extraHtml}
`, + { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } }, + ); +} + +function rateLimited(request: NextRequest): boolean { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ?? 'unknown'; + return !rateLimit(`opt-out:${ip}`, { maxRequests: 10, windowMs: 60_000 }); +} + +// GET: token valideren + bevestiging vragen (géén mutatie). +export async function GET(request: NextRequest) { + if (rateLimited(request)) { + 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 || new Date(lookup.recipient.chat_tokens_expire_at) < new Date()) { + return page('Link ongeldig', 'Deze afmeldlink is ongeldig of verlopen.'); + } + return page( + 'Afmelden voor chat-e-mails?', + 'Je ontvangt dan geen e-mails meer bij nieuwe chatberichten. De chat zelf blijft gewoon bereikbaar 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.'); + } +} + +// POST: expliciete bevestiging → optout-stempel + profielvoorkeur. +export async function POST(request: NextRequest) { + if (rateLimited(request)) { + 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 || new Date(lookup.recipient.chat_tokens_expire_at) < new Date()) { + 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) { + const { error: prefError } = await supabase + .from('marketplace_profiles') + .update({ email_notifications: false }) + .eq('user_id', sideUserId); + if (prefError) { + // Stamp op inquiry_recipients staat al (idempotent); laat de gebruiker + // opnieuw proberen zodat ook de account-brede voorkeur uit gaat. + throw new Error(`profile opt-out failed: ${prefError.message}`); + } + } + + 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..df23fe0 --- /dev/null +++ b/app/chat/[token]/layout.tsx @@ -0,0 +1,47 @@ +import type { Metadata } from 'next'; +import { getServiceClient } from '@/lib/marketplace-db'; + +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'; + +// De app registreert universal links op /i/{inquiry_id} (leerling) en +// /r/{recipient_id} (rijschool), NIET op /chat/{token}. Voor de iOS smart +// banner resolven we het token daarom server-side naar de juiste id-link, +// zodat "Open in de app" in het juiste gesprek landt i.p.v. het startscherm. +async function resolveAppArgument(token: string): Promise { + try { + const { data } = await getServiceClient().rpc('get_chat_context', { p_token: token }); + if (!data?.found) return undefined; + return data.role === 'rijschool' + ? `${BASE_URL}/r/${data.recipient_id}` + : `${BASE_URL}/i/${data.inquiry_id}`; + } catch { + return undefined; + } +} + +// 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 het gesprek +// - Android Chrome: install banner via het chat-specifieke web manifest +export async function generateMetadata( + { params }: { params: Promise<{ token: string }> }, +): Promise { + const { token } = await params; + const appArgument = await resolveAppArgument(token); + 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 ? { appArgument } : {}), + }, + 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..77c6783 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; + overflow-wrap: anywhere; +} + +.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/app/i/[id]/page.tsx b/app/i/[id]/page.tsx new file mode 100644 index 0000000..1df3052 --- /dev/null +++ b/app/i/[id]/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import MarketplaceAppFallback from '@/components/MarketplaceAppFallback'; + +export const metadata: Metadata = { + title: 'Open in de Ribba app', + robots: { index: false, follow: false }, +}; + +export default function InquiryDeepLinkFallback() { + return ; +} diff --git a/app/r/[id]/page.tsx b/app/r/[id]/page.tsx new file mode 100644 index 0000000..b0b6235 --- /dev/null +++ b/app/r/[id]/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; +import MarketplaceAppFallback from '@/components/MarketplaceAppFallback'; + +export const metadata: Metadata = { + title: 'Open in de Ribba app', + robots: { index: false, follow: false }, +}; + +export default function RecipientDeepLinkFallback() { + return ; +} diff --git a/components/MarketplaceAppFallback.tsx b/components/MarketplaceAppFallback.tsx new file mode 100644 index 0000000..3f56350 --- /dev/null +++ b/components/MarketplaceAppFallback.tsx @@ -0,0 +1,34 @@ +// Browser-fallback voor de marketplace universal links van de ribbaPro-app +// (link.ribba.app/i/{inquiry_id} en /r/{recipient_id}, ribbaPro#139). Met de +// app geïnstalleerd opent iOS/Android de app en komt de gebruiker hier nooit; +// zonder app tonen we de download-CTA. De web-chat zelf leeft op +// link.ribba.app/chat/{token} — die token-link staat als aparte knop in +// dezelfde e-mail, niet in deze URL (een kale /i- of /r-id geeft bewust geen +// chat-toegang; claimen vereist e-mail-match via de claim-RPC's). + +import RibbaLogo from '@/app/components/RibbaLogo'; +import { AppStoreBadge, GooglePlayBadge } from '@/app/components/StoreBadges'; + +export default function MarketplaceAppFallback() { + return ( +
+
+ +

Open deze link met de Ribba app

+

+ Deze link hoort bij een rijles-aanvraag en opent in de Ribba app. Download de app en + open de link daarna opnieuw — je logt in met het e-mailadres waarop je de aanvraag-mail + ontving. +

+
+ + +
+

+ Liever geen app? In de e-mail over je aanvraag staat ook een chat-knop die gewoon in je + browser werkt. +

+
+
+ ); +} diff --git a/components/chat/ChatGateway.tsx b/components/chat/ChatGateway.tsx new file mode 100644 index 0000000..fa607cd --- /dev/null +++ b/components/chat/ChatGateway.tsx @@ -0,0 +1,236 @@ +'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. +// +// Alle chat-semantiek loopt via de gedeelde SECURITY DEFINER RPC's uit de +// marketplace-migratie (get_chat_context / claim_inquiry / +// claim_inquiry_recipient) — exact hetzelfde contract als de ribbaPro-app, +// zodat web en app nooit uit elkaar kunnen lopen. + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createBrowserClient } from '@supabase/ssr'; +import type { SupabaseClient } from '@supabase/supabase-js'; +import RibbaLogo from '@/app/components/RibbaLogo'; +import OtpGate from './OtpGate'; +import ChatThread from './ChatThread'; +import type { ChatContext, ChatContextData } from '@/lib/marketplace-types'; + +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; +} + +// SQLSTATE 28000 = e-mail-mismatch / niet ingelogd in de claim-RPC's. +function isEmailMismatch(error: { code?: string; message?: string } | null): boolean { + return error?.code === '28000' || (error?.message ?? '').includes('komt niet overeen'); +} + +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 [otpKey, setOtpKey] = useState(0); + const claimInFlight = useRef(false); + + const claim = useCallback(async (context: ChatContextData): Promise => { + if (claimInFlight.current) return; + claimInFlight.current = true; + setPhase('claiming'); + const supabase = getSupabase(); + try { + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + setPhase('otp'); + return; + } + + if (context.role === 'rijschool') { + const { data, error } = await supabase.rpc('claim_inquiry_recipient', { + p_recipient_id: context.recipient_id, + }); + if (error) { + if (isEmailMismatch(error)) { + setMismatchEmail(session.user.email ?? null); + setPhase('otp'); + } else { + setErrorMsg('Er ging iets mis bij het openen van de chat.'); + setPhase('error'); + } + return; + } + setConversationId(data?.conversation_id ?? null); + setPhase(data?.conversation_id ? 'chat' : 'error'); + return; + } + + // role === 'leerling' + const { error } = await supabase.rpc('claim_inquiry', { + p_inquiry_id: context.inquiry_id, + }); + if (error) { + if (isEmailMismatch(error)) { + setMismatchEmail(session.user.email ?? null); + setPhase('otp'); + } else { + setErrorMsg('Er ging iets mis bij het openen van de chat.'); + setPhase('error'); + } + return; + } + if (context.conversation_id) { + setConversationId(context.conversation_id); + setPhase('chat'); + } else { + // Rijschool heeft de chat nog nooit geopend — kan alleen via een + // verouderde link (reply-mails bestaan pas ná een rijschool-bericht). + setPhase('waiting'); + } + } catch { + setErrorMsg('Kon geen verbinding maken. Probeer het opnieuw.'); + setPhase('error'); + } finally { + claimInFlight.current = false; + } + }, []); + + useEffect(() => { + (async () => { + try { + const supabase = getSupabase(); + const { data, error } = await supabase.rpc('get_chat_context', { p_token: token }); + const context = (data ?? null) as ChatContext | null; + + if (error || !context?.found) { + if (context && !context.found && context.expired) { + setErrorMsg('Deze chat-link is verlopen. In je meest recente e-mail over dit gesprek staat een werkende link.'); + } + setPhase('invalid'); + return; + } + setInfo(context); + + const { data: { session } } = await supabase.auth.getSession(); + if (session) { + await claim(context); + } else { + setPhase('otp'); + } + } catch { + // Onverwachte fout (netwerk, client-init) mag de spinner niet + // eeuwig laten draaien. + setPhase('invalid'); + } + })(); + }, [token, claim]); + + async function handleSwitchAccount() { + await getSupabase().auth.signOut(); + setMismatchEmail(null); + // Remount OtpGate zodat step/email/code resetten naar het begin. + setOtpKey((k) => k + 1); + } + + 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.'} +

+

+ Kom je er niet uit? Mail ons op 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 ? ` (${info.expected_email_masked})` : ''}. +

+ {mismatchEmail && ( +
+ Je bent ingelogd als {mismatchEmail}, maar deze chat hoort bij een ander adres.{' '} + +
+ )} + { void claim(info); }} + /> +
+
+ ); + } + + 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..2f918b4 --- /dev/null +++ b/components/chat/ChatThread.tsx @@ -0,0 +1,240 @@ +'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 { ChatContextData, ChatRole, InquiryRecipientStatus, MessageRow } from '@/lib/marketplace-types'; + +interface ChatThreadProps { + supabase: SupabaseClient; + conversationId: string; + role: ChatRole; + counterpartName: string; + status: InquiryRecipientStatus; + inquiryPreview: ChatContextData['inquiry_preview']; + contact: ChatContextData['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'; + + // read_at kan alleen via de gedeelde RPC gezet worden (geen client-UPDATE + // op messages) — zelfde pad als de ribbaPro-app straks gebruikt. + const markRead = useCallback(async () => { + await supabase.rpc('mark_messages_read', { p_conversation_id: conversationId }); + }, [supabase, conversationId]); + + useEffect(() => { + let channel: RealtimeChannel | null = null; + let cancelled = false; + + let initialLoadDone = false; + + // Initial load pas ná (poging tot) subscriben, zodat berichten die tussen + // fetch en subscribe binnenkomen niet verloren gaan; het id-dedupe in de + // INSERT-handler vangt de overlap af. + const loadMessages = async () => { + if (initialLoadDone || cancelled) return; + initialLoadDone = true; + 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((prev) => { + const loaded = (data as MessageRow[]) ?? []; + const extra = prev.filter((m) => !loaded.some((l) => l.id === m.id)); + return [...loaded, ...extra]; + }); + setLoading(false); + void markRead(); + }; + + (async () => { + const { data: { session } } = await supabase.auth.getSession(); + if (cancelled) return; + if (!session) { + setLoadError(true); + setLoading(false); + return; + } + userIdRef.current = session.user.id; + supabase.realtime.setAuth(session.access_token); + + 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((status) => { + // Ook bij een falend kanaal de historie tonen (chat zonder + // realtime is bruikbaarder dan een eeuwige spinner). + if (status === 'SUBSCRIBED' || status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { + void loadMessages(); + } + }); + })(); + + // 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.
+ )} +
+