feat(events): inscription (RSVP) événement + page admin Ops — Fête Targo 20 ans
Nouvelle fonctionnalité « Événements » (aucun courriel envoyé — moteur d'envoi prêt mais gaté). Hub (services/targo-hub) : - lib/events.js : config événement semée (fete-20-ans, bilingue FR/EN) ; page PUBLIQUE festive GET /rsvp/:id[?t=<jwt>&lang] (n° client · combien · courriel · allergies ; ?t pré-remplit + rattache via magic-link) ; POST /rsvp/:id/submit (résout le client au mieux via portal-auth.lookupCustomer, valide, stocke clé-par- client → re-soumission = mise à jour, pas de doublon) ; endpoints STAFF GET /events, GET /events/:id/rsvps (+ décompte total de personnes), DELETE ; helpers lien perso (generateCustomerToken) + courriel d'invitation bilingue. Réutilise les patrons rating.js. - server.js : dispatch /rsvp + /events → lib/events ; /rsvp/ ajouté à PUBLIC (page + soumission anonymes) ; /events ajouté à ALWAYS_ENFORCE (vue staff = PII → token requis). Ops (apps/ops) : - pages/EventsPage.vue (/evenements) : bandeau événement, KPIs (inscriptions / personnes attendues = total food truck / à vérifier), lien public copiable (affiche/QR/réseaux), carte invitation courriel (envoi gaté), table filtrable (DynamicFilter) + export CSV + suppression. Réutilise PageHeader/StatCard/DataTable/DynamicFilter/useCsvExport. - api/events.js (client hub), route /evenements, entrée menu « Événements » (PartyPopper). Vérifié : 30/30 tests unitaires hub (offline) ; page Ops rend proprement à 375px+desktop, 0 erreur console, dégradation gracieuse tant que le hub n'est pas déployé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7da4b1289c
commit
e4a8990711
27
apps/ops/src/api/events.js
Normal file
27
apps/ops/src/api/events.js
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* api/events.js — Client des endpoints Hub /events (vue staff RSVP).
|
||||||
|
* Miroir de services/targo-hub/lib/events.js. Passe par le proxy /ops/hub
|
||||||
|
* (jeton HUB_SERVICE_TOKEN injecté) → routes /events gatées (PII).
|
||||||
|
*
|
||||||
|
* listEvents() → { events:[{id,name,date_iso,active}] }
|
||||||
|
* listRsvps(eventId) → { event:{...,public_url}, responses:[], count, headcount, matched, unmatched }
|
||||||
|
* deleteRsvp(eventId, key) → { ok }
|
||||||
|
* sendInvite(eventId, body) → envoi d'invitations (GATÉ — non branché pour l'instant)
|
||||||
|
*/
|
||||||
|
import { HUB_URL } from 'src/config/hub'
|
||||||
|
|
||||||
|
async function hubFetch (path, { method = 'GET', body } = {}) {
|
||||||
|
const opts = { method, headers: { 'Content-Type': 'application/json' } }
|
||||||
|
if (body) opts.body = JSON.stringify(body)
|
||||||
|
const res = await fetch(`${HUB_URL}${path}`, opts)
|
||||||
|
const text = await res.text()
|
||||||
|
let data
|
||||||
|
try { data = text ? JSON.parse(text) : {} } catch { throw new Error(`Réponse invalide de ${path}`) }
|
||||||
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listEvents () { return hubFetch('/events') }
|
||||||
|
export async function listRsvps (eventId) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps`) }
|
||||||
|
export async function deleteRsvp (eventId, key) { return hubFetch(`/events/${encodeURIComponent(eventId)}/rsvps/${encodeURIComponent(key)}`, { method: 'DELETE' }) }
|
||||||
|
export async function sendInvite (eventId, body) { return hubFetch(`/events/${encodeURIComponent(eventId)}/send`, { method: 'POST', body }) }
|
||||||
|
|
@ -27,6 +27,7 @@ export const navItems = [
|
||||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' },
|
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' },
|
||||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', section: 'admin' },
|
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', section: 'admin' },
|
||||||
{ path: '/campaigns/pool', icon: 'Award', label: 'Récompenses', requires: 'manage_users', section: 'admin' },
|
{ path: '/campaigns/pool', icon: 'Award', label: 'Récompenses', requires: 'manage_users', section: 'admin' },
|
||||||
|
{ path: '/evenements', icon: 'PartyPopper', label: 'Événements', requires: 'manage_users', section: 'admin' },
|
||||||
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', section: 'admin' },
|
{ path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', section: 'admin' },
|
||||||
{ path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', section: 'admin' },
|
{ path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', section: 'admin' },
|
||||||
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', section: 'admin' },
|
{ path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', section: 'admin' },
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ import { navItems as allNavItems, navSections } from 'src/config/nav'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||||
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat,
|
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
// Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir
|
// Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir
|
||||||
// le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref → sûr. NotificationBell reste synchrone (barre du haut, toujours visible).
|
// le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref → sûr. NotificationBell reste synchrone (barre du haut, toujours visible).
|
||||||
|
|
@ -193,7 +193,7 @@ const OrchestratorDialog = defineAsyncComponent(() => import('src/components/sha
|
||||||
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
|
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
|
||||||
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
|
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
|
||||||
|
|
||||||
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat }
|
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper }
|
||||||
|
|
||||||
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
|
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
|
||||||
const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone()
|
const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone()
|
||||||
|
|
|
||||||
206
apps/ops/src/pages/EventsPage.vue
Normal file
206
apps/ops/src/pages/EventsPage.vue
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
<template>
|
||||||
|
<q-page class="q-pa-md">
|
||||||
|
<PageHeader title="Événements" />
|
||||||
|
|
||||||
|
<!-- Bandeau événement -->
|
||||||
|
<div class="ops-card q-pa-md q-mb-md" style="border-radius:12px">
|
||||||
|
<div class="row items-center ops-row-wrap q-gutter-sm">
|
||||||
|
<div class="ev-badge"><b>20</b><span>ANS</span></div>
|
||||||
|
<div class="col" style="min-width:0">
|
||||||
|
<div class="text-h6 text-weight-bold ellipsis">{{ ev.name || 'Fête Targo 20 ans' }}</div>
|
||||||
|
<div class="text-caption text-grey-7">
|
||||||
|
<q-icon name="event" size="16px" class="q-mr-xs" />{{ ev.when || '1ᵉʳ août, de 10 h à 15 h' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense icon="refresh" :loading="loading" @click="load" class="col-auto"><q-tooltip>Rafraîchir</q-tooltip></q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPIs -->
|
||||||
|
<div class="row q-col-gutter-sm q-mb-md">
|
||||||
|
<div class="col-6 col-sm-4"><StatCard label="Inscriptions" :value="count" icon="how_to_reg" color="primary" hint="Réponses reçues" /></div>
|
||||||
|
<div class="col-6 col-sm-4"><StatCard label="Personnes attendues" :value="headcount" icon="groups" color="green-7" hint="Total pour le food truck" /></div>
|
||||||
|
<div class="col-6 col-sm-4"><StatCard label="À vérifier" :value="unmatched" icon="help_outline" :color="unmatched ? 'orange-8' : 'grey-5'" :value-class="unmatched ? 'text-orange-8' : ''" hint="N° client non reconnu" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-col-gutter-md">
|
||||||
|
<!-- Colonne gauche : inviter -->
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<div class="ops-card q-pa-md q-mb-md" style="border-radius:12px">
|
||||||
|
<div class="text-subtitle2 text-weight-bold q-mb-sm"><q-icon name="link" class="q-mr-xs" />Lien public d'inscription</div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-sm">À mettre sur l'affiche, les réseaux sociaux ou un code QR. Le client saisit son numéro.</div>
|
||||||
|
<q-input dense outlined readonly :model-value="publicUrl" class="q-mb-sm">
|
||||||
|
<template #append><q-btn flat dense round icon="content_copy" @click="copy(publicUrl)"><q-tooltip>Copier</q-tooltip></q-btn></template>
|
||||||
|
</q-input>
|
||||||
|
<q-btn flat dense no-caps color="primary" icon="open_in_new" label="Ouvrir la page" :href="publicUrl" target="_blank" :disable="!publicUrl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ops-card q-pa-md" style="border-radius:12px">
|
||||||
|
<div class="text-subtitle2 text-weight-bold q-mb-sm"><q-icon name="mail" class="q-mr-xs" />Invitation par courriel</div>
|
||||||
|
<div class="text-caption text-grey-7 q-mb-sm">
|
||||||
|
Chaque client reçoit un lien <b>personnalisé</b> : son numéro est pré-rempli et son inscription est rattachée automatiquement.
|
||||||
|
</div>
|
||||||
|
<q-btn unelevated no-caps color="primary" icon="send" label="Préparer l'envoi" @click="prepSend" />
|
||||||
|
<div class="text-caption text-grey-5 q-mt-sm">Aucun courriel n'est envoyé sans confirmation.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Colonne droite : liste des inscrits -->
|
||||||
|
<div class="col-12 col-md-8">
|
||||||
|
<div class="ops-card q-pa-md" style="border-radius:12px">
|
||||||
|
<div class="row items-center ops-row-wrap q-mb-sm">
|
||||||
|
<div class="text-subtitle2 text-weight-bold">Inscriptions</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat dense no-caps icon="download" label="CSV" :disable="!filtered.length" @click="exportRows" />
|
||||||
|
</div>
|
||||||
|
<DynamicFilter v-model="filterState" dense search-placeholder="Nom, n° client, courriel…"
|
||||||
|
:chip-groups="chipGroups" :filtered-count="filtered.length" class="q-mb-sm" />
|
||||||
|
|
||||||
|
<q-banner v-if="error" dense rounded class="bg-orange-1 text-orange-9 q-mb-sm">
|
||||||
|
<template #avatar><q-icon name="cloud_off" /></template>
|
||||||
|
{{ error }}
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:rows="filtered" :columns="columns" row-key="key"
|
||||||
|
flat bordered dense :loading="loading"
|
||||||
|
:pagination="{ rowsPerPage: 50, sortBy: 'updated', descending: true }"
|
||||||
|
no-data-label="Aucune inscription pour l'instant.">
|
||||||
|
<template #body-cell-client="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="text-weight-medium ellipsis">{{ props.row.customer_name || props.row.customer_number || '—' }}</div>
|
||||||
|
<div class="text-caption text-grey-6">{{ props.row.customer_number }}</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-party="props">
|
||||||
|
<q-td :props="props" class="text-center"><q-chip dense square color="green-1" text-color="green-9" class="text-weight-bold">{{ props.value }}</q-chip></q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-allergies="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<span v-if="props.value" class="text-orange-9">⚠ {{ props.value }}</span>
|
||||||
|
<span v-else class="text-grey-5">—</span>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-matched="props">
|
||||||
|
<q-td :props="props" class="text-center">
|
||||||
|
<q-icon v-if="props.value" name="verified" color="green-7" size="20px"><q-tooltip>Client rattaché</q-tooltip></q-icon>
|
||||||
|
<q-icon v-else name="help_outline" color="orange-8" size="20px"><q-tooltip>N° client non reconnu — à vérifier</q-tooltip></q-icon>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-updated="props">
|
||||||
|
<q-td :props="props" class="text-caption">{{ fmtDate(props.value) }}</q-td>
|
||||||
|
</template>
|
||||||
|
<template #body-cell-actions="props">
|
||||||
|
<q-td :props="props" class="text-center">
|
||||||
|
<q-btn flat dense round size="sm" icon="delete" color="negative" @click="confirmDelete(props.row)"><q-tooltip>Retirer</q-tooltip></q-btn>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useQuasar } from 'quasar'
|
||||||
|
import PageHeader from 'src/components/shared/PageHeader.vue'
|
||||||
|
import StatCard from 'src/components/shared/StatCard.vue'
|
||||||
|
import DataTable from 'src/components/shared/DataTable.vue'
|
||||||
|
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
|
||||||
|
import { formatDateTimeShort as fmtDate } from 'src/composables/useFormatters'
|
||||||
|
import { useCsvExport } from 'src/composables/useCsvExport'
|
||||||
|
import { listRsvps, deleteRsvp } from 'src/api/events'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
const { exportCsv } = useCsvExport()
|
||||||
|
const EVENT_ID = 'fete-20-ans'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const ev = ref({})
|
||||||
|
const rows = ref([])
|
||||||
|
const count = ref(0)
|
||||||
|
const headcount = ref(0)
|
||||||
|
const matched = ref(0)
|
||||||
|
const unmatched = ref(0)
|
||||||
|
|
||||||
|
const publicUrl = computed(() => ev.value.public_url || '')
|
||||||
|
|
||||||
|
const filterState = ref({ search: '', chips: {} })
|
||||||
|
const chipGroups = [{ key: 'statut', label: 'Statut', icon: 'label', options: [{ value: 'matched', label: 'Rattachés', color: 'green-7' }, { value: 'unmatched', label: 'À vérifier', color: 'orange-8' }] }]
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'client', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
|
||||||
|
{ name: 'party', label: 'Personnes', field: 'party_size', align: 'center', sortable: true },
|
||||||
|
{ name: 'email', label: 'Courriel', field: 'email', align: 'left' },
|
||||||
|
{ name: 'allergies', label: 'Allergies', field: 'allergies', align: 'left' },
|
||||||
|
{ name: 'matched', label: 'Rattaché', field: 'matched', align: 'center', sortable: true },
|
||||||
|
{ name: 'updated', label: 'Répondu le', field: 'updated', align: 'left', sortable: true },
|
||||||
|
{ name: 'actions', label: '', field: 'key', align: 'center' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const st = filterState.value.chips.statut
|
||||||
|
const q = (filterState.value.search || '').toLowerCase().trim()
|
||||||
|
return rows.value.filter(r => {
|
||||||
|
if (st === 'matched' && !r.matched) return false
|
||||||
|
if (st === 'unmatched' && r.matched) return false
|
||||||
|
if (q && !((r.customer_name || '') + ' ' + (r.customer_number || '') + ' ' + (r.email || '')).toLowerCase().includes(q)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
loading.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const d = await listRsvps(EVENT_ID)
|
||||||
|
ev.value = d.event || {}
|
||||||
|
rows.value = d.responses || []
|
||||||
|
count.value = d.count || 0
|
||||||
|
headcount.value = d.headcount || 0
|
||||||
|
matched.value = d.matched || 0
|
||||||
|
unmatched.value = d.unmatched || 0
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Impossible de charger les inscriptions (' + e.message + '). La fonctionnalité doit être déployée sur le hub.'
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy (txt) {
|
||||||
|
if (!txt) return
|
||||||
|
navigator.clipboard?.writeText(txt).then(() => $q.notify({ type: 'positive', message: 'Lien copié', timeout: 1200 }))
|
||||||
|
.catch(() => $q.notify({ type: 'warning', message: txt }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRows () {
|
||||||
|
const headers = ['Client', 'N° client', 'Personnes', 'Courriel', 'Allergies', 'Rattaché', 'Langue', 'Répondu le']
|
||||||
|
const data = filtered.value.map(r => [r.customer_name || '', r.customer_number || '', r.party_size || 0, r.email || '', r.allergies || '', r.matched ? 'Oui' : 'Non', r.lang || '', fmtDate(r.updated)])
|
||||||
|
const totalRow = ['TOTAL', '', filtered.value.reduce((s, r) => s + (r.party_size || 0), 0), '', '', '', '', '']
|
||||||
|
exportCsv(`inscriptions_${EVENT_ID}`, headers, data, { totalRow })
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete (row) {
|
||||||
|
$q.dialog({ title: 'Retirer l\'inscription', message: `Retirer ${row.customer_name || row.customer_number || 'cette inscription'} (${row.party_size} pers.) ?`, cancel: true, persistent: true })
|
||||||
|
.onOk(async () => {
|
||||||
|
try { await deleteRsvp(EVENT_ID, row.key); $q.notify({ type: 'positive', message: 'Retiré' }); load() }
|
||||||
|
catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepSend () {
|
||||||
|
$q.dialog({
|
||||||
|
title: "Envoi d'invitations",
|
||||||
|
message: "L'envoi de courriels personnalisés (un lien pré-rempli par client) est prêt côté moteur, mais volontairement non branché : il faut d'abord choisir l'audience (tous les clients, un segment, ou un lot test). En attendant, le lien public ci-contre est déjà utilisable sur l'affiche et les réseaux sociaux.",
|
||||||
|
ok: { label: 'Compris', color: 'primary', noCaps: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ev-badge { display:flex; flex-direction:column; align-items:center; justify-content:center; width:52px; height:52px; border-radius:50%; background:#21a34a; color:#fff; font-weight:800; line-height:1; flex:0 0 auto; }
|
||||||
|
.ev-badge b { font-size:1.15rem; }
|
||||||
|
.ev-badge span { font-size:.52rem; letter-spacing:1px; }
|
||||||
|
</style>
|
||||||
|
|
@ -57,6 +57,7 @@ const routes = [
|
||||||
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
{ path: 'agent-flows', component: () => import('src/pages/AgentFlowsPage.vue') },
|
||||||
{ path: 'taches-pilote', component: () => import('src/pages/TaskGraphPage.vue') },
|
{ path: 'taches-pilote', component: () => import('src/pages/TaskGraphPage.vue') },
|
||||||
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
{ path: 'network', component: () => import('src/pages/NetworkPage.vue') },
|
||||||
|
{ path: 'evenements', component: () => import('src/pages/EventsPage.vue') },
|
||||||
{ path: 'sync-legacy', component: () => import('src/pages/LegacySyncPage.vue') },
|
{ path: 'sync-legacy', component: () => import('src/pages/LegacySyncPage.vue') },
|
||||||
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
// Gift campaigns — list, new wizard (CSV upload + matching), per-campaign detail with live SSE updates
|
||||||
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
|
{ path: 'campaigns', component: () => import('src/modules/campaigns/pages/CampaignsListPage.vue') },
|
||||||
|
|
|
||||||
409
services/targo-hub/lib/events.js
Normal file
409
services/targo-hub/lib/events.js
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* Événements clients + inscription (RSVP).
|
||||||
|
*
|
||||||
|
* - Page PUBLIQUE tokenisable : GET /rsvp/<eventId>[?t=<jwt>&lang=fr|en]
|
||||||
|
* Le formulaire demande : n° client, combien serez-vous, courriel, allergies (optionnel).
|
||||||
|
* Avec ?t=<jwt magic-link client> → n° client + nom + courriel pré-remplis et rattachés
|
||||||
|
* automatiquement ; sans jeton (affiche/réseaux sociaux) → le client saisit son numéro,
|
||||||
|
* validé au mieux contre ERPNext (portal-auth.lookupCustomer).
|
||||||
|
* - POST /rsvp/<eventId>/submit → enregistre la réponse (fichier, comme rating.js/campaigns),
|
||||||
|
* clé par client → une re-soumission MET À JOUR (pas de doublon).
|
||||||
|
* - Endpoints STAFF (gatés) : GET /events, GET /events/<id>/rsvps (+ décompte total de personnes),
|
||||||
|
* DELETE /events/<id>/rsvps/<key>.
|
||||||
|
* - Helpers d'envoi (lien perso + courriel bilingue) réutilisés par l'étape d'invitation — l'ENVOI
|
||||||
|
* lui-même est une action séparée et gatée, pas déclenchée ici.
|
||||||
|
*
|
||||||
|
* Réutilise : magic-link.generateCustomerToken/verifyJwt · portal-auth.lookupCustomer · helpers (json/parseBody/read|writeJsonFile/log).
|
||||||
|
*/
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const cfg = require('./config')
|
||||||
|
const { log, json, parseBody, readJsonFile, writeJsonFile } = require('./helpers')
|
||||||
|
const { generateCustomerToken, verifyJwt } = require('./magic-link')
|
||||||
|
|
||||||
|
const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events'
|
||||||
|
try { fs.mkdirSync(DATA_DIR, { recursive: true }) } catch { /* best-effort */ }
|
||||||
|
const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
|
||||||
|
const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
|
||||||
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
||||||
|
const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr'
|
||||||
|
const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''
|
||||||
|
|
||||||
|
// ── Config événements (semé : Fête Targo 20 ans) ───────────────────────────
|
||||||
|
// La copie vit ici ; les réponses vivent dans data/events/<id>.json. Pour changer
|
||||||
|
// la date/le texte, éditer SEED (ou, plus tard, un fichier meta par événement).
|
||||||
|
const SEED = {
|
||||||
|
'fete-20-ans': {
|
||||||
|
id: 'fete-20-ans',
|
||||||
|
active: true,
|
||||||
|
date_iso: '2026-08-01',
|
||||||
|
fr: {
|
||||||
|
name: 'Fête Targo 20 ans',
|
||||||
|
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre fête de 20 ans !",
|
||||||
|
when: '1ᵉʳ août, de 10 h à 15 h',
|
||||||
|
body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.",
|
||||||
|
program_intro: 'Au programme',
|
||||||
|
program: [
|
||||||
|
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
|
||||||
|
{ icon: '🎧', label: 'DJ' },
|
||||||
|
{ icon: '🎪', label: 'Jeux gonflables' },
|
||||||
|
{ icon: '🎨', label: 'Maquillage' },
|
||||||
|
{ icon: '🎁', label: 'Et plusieurs surprises' },
|
||||||
|
],
|
||||||
|
program_line: "Le tout dans une ambiance familiale et conviviale.",
|
||||||
|
limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.",
|
||||||
|
closing: "Nous avons hâte de vous retrouver pour célébrer ensemble !",
|
||||||
|
notes: [
|
||||||
|
'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
|
||||||
|
"Vous recevrez un coupon repas sur place (à l'accueil).",
|
||||||
|
],
|
||||||
|
f_number: 'Quel est votre numéro client ?',
|
||||||
|
f_party: 'Combien serez-vous ?',
|
||||||
|
f_email: 'Quelle est votre adresse courriel ?',
|
||||||
|
f_allerg: 'Allergies ou restrictions alimentaires (optionnel)',
|
||||||
|
f_party_ph: 'Nombre de personnes',
|
||||||
|
submit: 'Confirmer ma présence',
|
||||||
|
greet: (n) => n ? `Bonjour ${n} !` : '',
|
||||||
|
thanks_title: 'Merci, votre présence est confirmée ! 🎉',
|
||||||
|
thanks_body: "On a hâte de vous voir le 1ᵉʳ août. Vous recevrez votre coupon repas à l'accueil.",
|
||||||
|
thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
|
||||||
|
err_number: 'Veuillez entrer votre numéro client.',
|
||||||
|
err_party: 'Veuillez indiquer combien de personnes seront présentes.',
|
||||||
|
err_email: 'Veuillez entrer une adresse courriel valide.',
|
||||||
|
err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
|
||||||
|
foot: 'TARGO — votre fournisseur Internet local',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
name: "Targo's 20th Anniversary",
|
||||||
|
tagline: 'The whole Targo team is happy to invite you to our 20th anniversary party!',
|
||||||
|
when: 'August 1st, 10 a.m. to 3 p.m.',
|
||||||
|
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
|
||||||
|
program_intro: 'On the program',
|
||||||
|
program: [
|
||||||
|
{ icon: '🚚', label: 'Food truck', sub: 'meal included' },
|
||||||
|
{ icon: '🎧', label: 'DJ' },
|
||||||
|
{ icon: '🎪', label: 'Bouncy castles' },
|
||||||
|
{ icon: '🎨', label: 'Face painting' },
|
||||||
|
{ icon: '🎁', label: 'And many surprises' },
|
||||||
|
],
|
||||||
|
program_line: 'All in a warm, family-friendly atmosphere.',
|
||||||
|
limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.',
|
||||||
|
closing: "We can't wait to celebrate together!",
|
||||||
|
notes: [
|
||||||
|
'If you have allergies, the menu may not suit you.',
|
||||||
|
'You will receive a meal coupon on site (at the welcome desk).',
|
||||||
|
],
|
||||||
|
f_number: 'What is your customer number?',
|
||||||
|
f_party: 'How many will attend?',
|
||||||
|
f_email: 'What is your email address?',
|
||||||
|
f_allerg: 'Allergies or dietary restrictions (optional)',
|
||||||
|
f_party_ph: 'Number of people',
|
||||||
|
submit: 'Confirm my attendance',
|
||||||
|
greet: (n) => n ? `Hi ${n}!` : '',
|
||||||
|
thanks_title: "Thank you, you're confirmed! 🎉",
|
||||||
|
thanks_body: 'We look forward to seeing you on August 1st. Pick up your meal coupon at the welcome desk.',
|
||||||
|
thanks_update: 'You can change your answer anytime from this link.',
|
||||||
|
err_number: 'Please enter your customer number.',
|
||||||
|
err_party: 'Please tell us how many people will attend.',
|
||||||
|
err_email: 'Please enter a valid email address.',
|
||||||
|
err_generic: 'Something went wrong. Please try again or contact us.',
|
||||||
|
foot: 'TARGO — your local Internet provider',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEvent (eventId) { return SEED[eventId] || null }
|
||||||
|
function listEvents () { return Object.values(SEED).map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name })) }
|
||||||
|
|
||||||
|
// ── Stockage des réponses (une map par événement, clé = client) ────────────
|
||||||
|
function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) }
|
||||||
|
function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} }
|
||||||
|
function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) }
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
|
||||||
|
function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) } // <1 / non-numérique = invalide (→ 400) ; borne haute 30
|
||||||
|
|
||||||
|
// ── Page publique (festive, mobile-first, bilingue) ────────────────────────
|
||||||
|
function page (event, lang, prefill = {}) {
|
||||||
|
lang = normLang(lang)
|
||||||
|
const t = event[lang]
|
||||||
|
const other = lang === 'fr' ? 'en' : 'fr'
|
||||||
|
const known = !!prefill.customer_id
|
||||||
|
const greet = t.greet(firstName(prefill.name))
|
||||||
|
const program = t.program.map(p =>
|
||||||
|
`<div class="prog"><div class="prog-ic">${p.icon}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>`
|
||||||
|
).join('')
|
||||||
|
const notes = t.notes.map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('')
|
||||||
|
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${esc(t.name)}</title>
|
||||||
|
<style>
|
||||||
|
:root{--g:#21a34a;--navy:#173a5e;--blue:#29a3dd;--orange:#f7941d;--yellow:#ffcd05;--ink:#1e293b;--muted:#64748b;--line:#e6ebe8}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:var(--ink);
|
||||||
|
background:radial-gradient(1200px 400px at 50% -120px,#eafbf0 0%,transparent 70%),linear-gradient(180deg,#f7fbff 0%,#ffffff 60%);min-height:100vh}
|
||||||
|
.confetti{height:8px;background:linear-gradient(90deg,var(--g) 0 20%,var(--blue) 20% 40%,var(--yellow) 40% 60%,var(--orange) 60% 80%,var(--navy) 80% 100%)}
|
||||||
|
.wrap{max-width:600px;margin:0 auto;padding:26px 18px 46px}
|
||||||
|
.lang{text-align:right;margin-bottom:6px}
|
||||||
|
.lang a{color:var(--muted);text-decoration:none;font-size:.82rem;border:1px solid var(--line);padding:4px 10px;border-radius:999px}
|
||||||
|
.head{text-align:center;margin:6px 0 18px}
|
||||||
|
.badge{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;
|
||||||
|
background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px}
|
||||||
|
.badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px}
|
||||||
|
.logo{height:34px;width:auto;display:block;margin:0 auto 14px}
|
||||||
|
h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--navy)}
|
||||||
|
.tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px}
|
||||||
|
.card{background:#fff;border:1px solid #eef2f0;border-radius:20px;box-shadow:0 16px 44px rgba(23,58,94,.09);padding:26px 22px;margin-bottom:16px}
|
||||||
|
p{line-height:1.6;font-size:1rem;margin:0 0 12px;color:#334155}
|
||||||
|
.when{display:flex;align-items:center;justify-content:center;gap:10px;background:var(--navy);color:#fff;font-weight:700;
|
||||||
|
font-size:1.12rem;border-radius:14px;padding:13px 18px;margin:4px 0 16px}
|
||||||
|
.when .cal{font-size:1.3rem}
|
||||||
|
.prog-wrap{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:6px 0 4px}
|
||||||
|
.prog{flex:1 1 90px;max-width:110px;text-align:center;background:#f8fafc;border:1px solid #eef2f6;border-radius:14px;padding:12px 6px}
|
||||||
|
.prog-ic{font-size:1.7rem;line-height:1}
|
||||||
|
.prog-lb{margin-top:6px;font-size:.78rem;font-weight:700;color:var(--navy)}
|
||||||
|
.prog-lb span{display:block;font-weight:500;color:var(--muted);font-size:.7rem}
|
||||||
|
.limited{display:flex;gap:10px;align-items:flex-start;background:#fff7ed;border:1px solid #fed7aa;border-radius:12px;padding:12px 14px;margin:14px 0 2px}
|
||||||
|
.limited .i{font-size:1.1rem}
|
||||||
|
.limited p{margin:0;font-size:.92rem;color:#9a3412}
|
||||||
|
.greet{font-weight:700;color:var(--navy);font-size:1.05rem;margin:0 0 10px}
|
||||||
|
label{display:block;font-weight:600;font-size:.92rem;color:var(--navy);margin:14px 0 6px}
|
||||||
|
input,textarea{width:100%;border:1.5px solid var(--line);border-radius:12px;padding:13px 15px;font:inherit;font-size:1rem;background:#fcfdfc;transition:border-color .15s,box-shadow .15s}
|
||||||
|
input:focus,textarea:focus{outline:none;border-color:var(--g);box-shadow:0 0 0 4px rgba(33,163,74,.14)}
|
||||||
|
input[readonly]{background:#f1f5f9;color:var(--muted)}
|
||||||
|
textarea{min-height:74px;resize:vertical}
|
||||||
|
.err{color:#dc2626;font-size:.85rem;margin-top:5px;display:none}
|
||||||
|
button{margin-top:20px;width:100%;background:var(--g);color:#fff;border:0;border-radius:14px;padding:15px;font:inherit;font-weight:800;
|
||||||
|
font-size:1.05rem;cursor:pointer;box-shadow:0 10px 24px rgba(33,163,74,.3);transition:filter .15s,transform .08s}
|
||||||
|
button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}button:disabled{opacity:.6;cursor:default}
|
||||||
|
.notes{margin-top:16px}.note{color:var(--muted);font-size:.8rem;line-height:1.5;margin-top:3px}
|
||||||
|
.foot{text-align:center;color:#9aa7a0;font-size:.8rem;margin-top:20px}
|
||||||
|
.thanks{text-align:center;padding:14px 4px}
|
||||||
|
.thanks .big{font-size:3rem;line-height:1;margin-bottom:10px}
|
||||||
|
.thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px}
|
||||||
|
.hidden{display:none}
|
||||||
|
</style></head>
|
||||||
|
<body><div class="confetti"></div><div class="wrap">
|
||||||
|
<div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div>
|
||||||
|
<div class="head">
|
||||||
|
<div class="badge"><b>20</b><span>ANS</span></div>
|
||||||
|
<img class="logo" src="${LOGO}" alt="TARGO">
|
||||||
|
<h1>${esc(t.name)}</h1>
|
||||||
|
<div class="tag">${esc(t.tagline)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="invite" class="card">
|
||||||
|
<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>
|
||||||
|
<p>${esc(t.body)}</p>
|
||||||
|
<div class="prog-wrap">${program}</div>
|
||||||
|
<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>
|
||||||
|
<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="formCard" class="card">
|
||||||
|
${greet ? `<div class="greet">${esc(greet)}</div>` : ''}
|
||||||
|
<form id="rsvp" onsubmit="return submitRsvp(event)">
|
||||||
|
<label for="cn">${esc(t.f_number)}</label>
|
||||||
|
<input id="cn" name="customer_number" value="${esc(prefill.customer_number || '')}" ${known ? 'readonly' : ''} placeholder="C-12345" autocomplete="off">
|
||||||
|
<div class="err" id="e_cn">${esc(t.err_number)}</div>
|
||||||
|
|
||||||
|
<label for="ps">${esc(t.f_party)}</label>
|
||||||
|
<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">
|
||||||
|
<div class="err" id="e_ps">${esc(t.err_party)}</div>
|
||||||
|
|
||||||
|
<label for="em">${esc(t.f_email)}</label>
|
||||||
|
<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" placeholder="vous@exemple.com" autocomplete="email">
|
||||||
|
<div class="err" id="e_em">${esc(t.err_email)}</div>
|
||||||
|
|
||||||
|
<label for="al">${esc(t.f_allerg)}</label>
|
||||||
|
<textarea id="al" name="allergies" maxlength="500"></textarea>
|
||||||
|
|
||||||
|
<button type="submit" id="sub">${esc(t.submit)}</button>
|
||||||
|
<div class="err" id="e_gen" style="text-align:center">${esc(t.err_generic)}</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="thanks" class="card hidden">
|
||||||
|
<div class="thanks">
|
||||||
|
<div class="big">🎉</div>
|
||||||
|
<h2>${esc(t.thanks_title)}</h2>
|
||||||
|
<p>${esc(t.thanks_body)}</p>
|
||||||
|
<p style="color:var(--muted);font-size:.9rem">${esc(t.thanks_update)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes">${notes}</div>
|
||||||
|
<div class="foot">${esc(t.foot)} · targo.ca</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var TOKEN=${JSON.stringify(prefill.token || '')};
|
||||||
|
var EVENT=${JSON.stringify(event.id)};
|
||||||
|
function show(id){document.getElementById(id).classList.remove('hidden')}
|
||||||
|
function hide(id){document.getElementById(id).classList.add('hidden')}
|
||||||
|
function submitRsvp(e){
|
||||||
|
e.preventDefault();
|
||||||
|
var f=e.target;
|
||||||
|
['e_cn','e_ps','e_em','e_gen'].forEach(function(id){document.getElementById(id).style.display='none'});
|
||||||
|
var cn=(f.customer_number.value||'').trim();
|
||||||
|
var ps=parseInt(f.party_size.value,10);
|
||||||
|
var em=(f.email.value||'').trim();
|
||||||
|
var ok=true;
|
||||||
|
if(!cn){document.getElementById('e_cn').style.display='block';ok=false}
|
||||||
|
if(!ps||ps<1){document.getElementById('e_ps').style.display='block';ok=false}
|
||||||
|
if(!em||!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(em)){document.getElementById('e_em').style.display='block';ok=false}
|
||||||
|
if(!ok)return false;
|
||||||
|
var b=document.getElementById('sub');b.disabled=true;
|
||||||
|
fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({token:TOKEN,customer_number:cn,party_size:ps,email:em,allergies:(f.allergies.value||'').trim(),lang:${JSON.stringify(lang)}})})
|
||||||
|
.then(function(r){return r.json()})
|
||||||
|
.then(function(d){if(d&&d.ok){hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'})}else{b.disabled=false;document.getElementById('e_gen').style.display='block'}})
|
||||||
|
.catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Résolution client (au mieux) : jeton d'abord, sinon numéro saisi ───────
|
||||||
|
async function resolveCustomer (token, customerNumber) {
|
||||||
|
if (token) {
|
||||||
|
const p = verifyJwt(token)
|
||||||
|
if (p && p.scope === 'customer' && p.sub) {
|
||||||
|
return { customer_id: p.sub, customer_name: p.name || '', email: p.email || '', matched: true, via: 'token' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw = String(customerNumber || '').trim()
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const { lookupCustomer } = require('./portal-auth')
|
||||||
|
const c = await lookupCustomer(raw)
|
||||||
|
if (c && c.name) return { customer_id: c.name, customer_name: c.customer_name || '', email: c.email_id || '', matched: true, via: 'form' }
|
||||||
|
} catch (e) { log('events resolveCustomer: ' + e.message) }
|
||||||
|
}
|
||||||
|
return { customer_id: null, customer_name: '', email: '', matched: false, via: 'form' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFor (resolved, rawNumber) {
|
||||||
|
if (resolved.customer_id) return 'c:' + resolved.customer_id
|
||||||
|
return 'n:' + String(rawNumber || '').trim().toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lien perso + courriel d'invitation (utilisés par l'étape d'envoi, gatée) ─
|
||||||
|
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24) {
|
||||||
|
const tok = generateCustomerToken(customerId, name, email, ttlHours)
|
||||||
|
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}`
|
||||||
|
}
|
||||||
|
function inviteSubject (eventId, lang) {
|
||||||
|
const e = getEvent(eventId); if (!e) return ''
|
||||||
|
return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
|
||||||
|
}
|
||||||
|
function inviteEmail (eventId, lang, name, rsvpUrl) {
|
||||||
|
const e = getEvent(eventId); if (!e) return ''
|
||||||
|
lang = normLang(lang); const t = e[lang]
|
||||||
|
const prog = t.program.map(p => `${p.icon} ${esc(p.label)}`).join(' · ')
|
||||||
|
const cta = t.submit
|
||||||
|
return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
|
||||||
|
+ `<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
|
||||||
|
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:36px 16px">`
|
||||||
|
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:16px;overflow:hidden;border:1px solid #e2e8f0">`
|
||||||
|
+ `<tr><td style="height:8px;background:linear-gradient(90deg,#21a34a 0 20%,#29a3dd 20% 40%,#ffcd05 40% 60%,#f7941d 60% 80%,#173a5e 80% 100%);font-size:0;line-height:0"> </td></tr>`
|
||||||
|
+ `<tr><td style="padding:26px 30px 6px" align="center"><img src="${LOGO}" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr>`
|
||||||
|
+ `<tr><td style="padding:6px 30px 0" align="center"><h1 style="margin:8px 0 4px;font-size:24px;font-weight:800;color:#173a5e">${esc(t.name)}</h1>`
|
||||||
|
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(t.tagline)}</p></td></tr>`
|
||||||
|
+ `<tr><td style="padding:18px 30px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
|
||||||
|
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
|
||||||
|
+ `<tr><td style="padding:14px 30px 6px" align="center"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#173a5e;border-radius:12px;padding:12px 22px;color:#fff;font-size:17px;font-weight:700">📅 ${esc(t.when)}</td></tr></table></td></tr>`
|
||||||
|
+ `<tr><td style="padding:10px 30px 4px" align="center"><p style="margin:0;font-size:14px;color:#334155">${prog}</p></td></tr>`
|
||||||
|
+ `<tr><td style="padding:8px 30px 4px"><p style="margin:0;font-size:14px;line-height:1.6;color:#9a3412;background:#fff7ed;border:1px solid #fed7aa;border-radius:10px;padding:11px 14px">⏳ ${esc(t.limited)}</p></td></tr>`
|
||||||
|
+ `<tr><td style="padding:20px 30px 8px" align="center"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:15px 34px;color:#fff;font-size:16px;font-weight:800;text-decoration:none">${esc(cta)} →</a></td></tr></table></td></tr>`
|
||||||
|
+ `<tr><td style="padding:6px 30px 22px" align="center"><p style="margin:0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>`
|
||||||
|
+ `<tr><td style="background:#f8fafc;padding:14px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">${esc(t.notes[0])}<br>${esc(t.notes[1])}<br><br>${esc(t.foot)}</td></tr>`
|
||||||
|
+ `</table></td></tr></table></body></html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vue staff : liste + décompte ───────────────────────────────────────────
|
||||||
|
function listRsvps (eventId) {
|
||||||
|
const m = loadResp(eventId)
|
||||||
|
const responses = Object.entries(m).map(([key, r]) => ({ key, ...r }))
|
||||||
|
.sort((a, b) => String(b.updated || b.created || '').localeCompare(String(a.updated || a.created || '')))
|
||||||
|
const headcount = responses.reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0)
|
||||||
|
const matched = responses.filter(r => r.matched).length
|
||||||
|
return { responses, count: responses.length, headcount, matched, unmatched: responses.length - matched }
|
||||||
|
}
|
||||||
|
function deleteRsvp (eventId, key) { const m = loadResp(eventId); if (m[key]) { delete m[key]; saveResp(eventId, m); return true } return false }
|
||||||
|
|
||||||
|
// ── Routage HTTP ───────────────────────────────────────────────────────────
|
||||||
|
async function handle (req, res, method, path, url) {
|
||||||
|
try {
|
||||||
|
// Page publique : GET /rsvp/<eventId>
|
||||||
|
let mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)$/)
|
||||||
|
if (mm && method === 'GET') {
|
||||||
|
const event = getEvent(mm[1])
|
||||||
|
if (!event || !event.active) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('<h1>Événement introuvable</h1>') }
|
||||||
|
const token = url.searchParams.get('t') || ''
|
||||||
|
let lang = url.searchParams.get('lang') || 'fr'
|
||||||
|
const prefill = { token }
|
||||||
|
if (token) {
|
||||||
|
const p = verifyJwt(token)
|
||||||
|
if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang }
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
||||||
|
return res.end(page(event, lang, prefill))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soumission publique : POST /rsvp/<eventId>/submit
|
||||||
|
mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)\/submit$/)
|
||||||
|
if (mm && method === 'POST') {
|
||||||
|
const eventId = mm[1]
|
||||||
|
const event = getEvent(eventId)
|
||||||
|
if (!event || !event.active) return json(res, 404, { ok: false, error: 'event_not_found' })
|
||||||
|
const b = await parseBody(req)
|
||||||
|
const party = clampParty(b.party_size)
|
||||||
|
const email = String(b.email || '').trim()
|
||||||
|
if (!party) return json(res, 400, { ok: false, error: 'party_size' })
|
||||||
|
if (!EMAIL_RE.test(email)) return json(res, 400, { ok: false, error: 'email' })
|
||||||
|
const resolved = await resolveCustomer(b.token, b.customer_number)
|
||||||
|
const key = keyFor(resolved, b.customer_number)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const m = loadResp(eventId)
|
||||||
|
const prev = m[key] || {}
|
||||||
|
m[key] = {
|
||||||
|
event_id: eventId,
|
||||||
|
customer_id: resolved.customer_id,
|
||||||
|
customer_number: String(b.customer_number || resolved.customer_id || '').trim(),
|
||||||
|
customer_name: resolved.customer_name || prev.customer_name || '',
|
||||||
|
party_size: party,
|
||||||
|
email: email || resolved.email || prev.email || '',
|
||||||
|
allergies: String(b.allergies || '').trim().slice(0, 500),
|
||||||
|
lang: normLang(b.lang),
|
||||||
|
matched: resolved.matched,
|
||||||
|
via: resolved.via,
|
||||||
|
created: prev.created || now,
|
||||||
|
updated: now,
|
||||||
|
}
|
||||||
|
saveResp(eventId, m)
|
||||||
|
log(`RSVP ${eventId} — ${key} · ${party}p · ${resolved.matched ? 'matched' : 'unmatched'}`)
|
||||||
|
return json(res, 200, { ok: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staff : GET /events (liste des événements)
|
||||||
|
if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() })
|
||||||
|
|
||||||
|
// Staff : GET /events/<id>/rsvps · DELETE /events/<id>/rsvps/<key>
|
||||||
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
|
||||||
|
if (mm && method === 'GET') {
|
||||||
|
const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
|
||||||
|
const data = listRsvps(mm[1])
|
||||||
|
return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, public_url: `${pub()}/rsvp/${event.id}` }, ...data })
|
||||||
|
}
|
||||||
|
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
|
||||||
|
if (mm && method === 'DELETE') {
|
||||||
|
const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2]))
|
||||||
|
return json(res, ok ? 200 : 404, { ok })
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, 404, { error: 'not found' })
|
||||||
|
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, page, normLang }
|
||||||
|
|
@ -89,6 +89,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
const PUBLIC = [
|
const PUBLIC = [
|
||||||
'/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store',
|
'/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store',
|
||||||
'/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/', '/rate',
|
'/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/', '/rate',
|
||||||
|
'/rsvp/', // inscription événement (RSVP) : page + soumission publiques ; ?t=<jwt client> pré-remplit. La vue staff /events reste gatée (ALWAYS_ENFORCE).
|
||||||
'/acte/form', '/acte/data', '/acte/submit', '/acte/photo', '/acte/entente/doc', // page sous-traitant payé à l'acte (token vérifié dans le handler) — /acte/send /acte/rates /acte/list /acte/img /acte/entente (édition) restent gatés
|
'/acte/form', '/acte/data', '/acte/submit', '/acte/photo', '/acte/entente/doc', // page sous-traitant payé à l'acte (token vérifié dans le handler) — /acte/send /acte/rates /acte/list /acte/img /acte/entente (édition) restent gatés
|
||||||
'/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/after-dial', '/voice/twiml', '/voice/status',
|
'/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/after-dial', '/voice/twiml', '/voice/status',
|
||||||
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
|
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
|
||||||
|
|
@ -122,7 +123,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
'/devices', '/email-queue', '/campaigns', '/giftbit',
|
'/devices', '/email-queue', '/campaigns', '/giftbit',
|
||||||
'/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab',
|
'/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab',
|
||||||
'/network', '/sync', '/legacy-payments', '/telephony', '/flow',
|
'/network', '/sync', '/legacy-payments', '/telephony', '/flow',
|
||||||
'/conversations', '/dispatch', '/sla',
|
'/conversations', '/dispatch', '/sla', '/events', // /events/* = vue staff RSVP (PII : noms/courriels/décompte) → token requis
|
||||||
'/payments/charge', '/payments/refund', '/payments/ppa-run', '/payments/send-link',
|
'/payments/charge', '/payments/refund', '/payments/ppa-run', '/payments/send-link',
|
||||||
'/payments/record-invoice', // enregistrement manuel d'un paiement contre facture (crée un Payment Entry) → staff only
|
'/payments/record-invoice', // enregistrement manuel d'un paiement contre facture (crée un Payment Entry) → staff only
|
||||||
]
|
]
|
||||||
|
|
@ -286,6 +287,7 @@ const server = http.createServer(async (req, res) => {
|
||||||
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
|
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
|
||||||
if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path)
|
if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path)
|
||||||
if (path.startsWith('/rate')) return require('./lib/rating').handle(req, res, method, path, url)
|
if (path.startsWith('/rate')) return require('./lib/rating').handle(req, res, method, path, url)
|
||||||
|
if (path.startsWith('/rsvp') || path.startsWith('/events')) return require('./lib/events').handle(req, res, method, path, url)
|
||||||
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
|
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
|
||||||
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
|
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
|
||||||
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
|
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user