gigafibre-fsm/apps/dispatch/src/pages/BookingPage.vue
louispaulb 7da22ff132 merge: import dispatch-app into apps/dispatch/ (17 commits preserved)
Integrates the Dispatch PWA (Vue/Quasar) into the gigafibre-fsm monorepo.
Full git history accessible via `git log -- apps/dispatch/`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:08:51 -04:00

584 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { MAPBOX_TOKEN } from 'src/config/erpnext'
import { createServiceRequest } from 'src/api/service-request'
const router = useRouter()
// ── Services télécom ──────────────────────────────────────────────────────────
const SERVICES = [
{ id: 'internet', icon: '🌐', label: 'Internet', desc: 'Connexion lente, coupures, Wi-Fi' },
{ id: 'tv', icon: '📺', label: 'Télévision', desc: 'Câble, satellite, IPTV, décodeur' },
{ id: 'telephone', icon: '📞', label: 'Téléphonie', desc: 'Résidentiel, VoIP, interphones' },
{ id: 'multi', icon: '🔧', label: 'Services multiples', desc: 'Problème combiné' },
]
const PROBLEMS = {
internet: [
'Pas de connexion internet', 'Connexion intermittente', 'Vitesse très lente',
'Signal Wi-Fi faible', 'Modem / routeur défaillant', 'Installation câblage réseau',
'Configuration réseau (IP, DNS)', 'Autre',
],
tv: [
"Pas de signal TV", 'Image pixelisée / gelée', 'Canaux manquants',
'Décodeur défaillant', 'Installation antenne / câble', 'Configuration IPTV',
'Télécommande défectueuse', 'Autre',
],
telephone: [
"Pas de tonalité", 'Mauvaise qualité audio', 'Ligne coupée',
'Installation VoIP', 'Portabilité de numéro', 'Installation câblage téléphonique',
'Configuration central téléphonique', 'Autre',
],
multi: ['Décrire le problème dans la zone de texte ci-dessous'],
}
const TIME_SLOTS = [
{ id: 'morning', label: 'Matin', sub: '8h12h', icon: '🌅' },
{ id: 'afternoon', label: 'Après-midi', sub: '12h17h', icon: '☀️' },
{ id: 'evening', label: 'Soir', sub: '17h20h', icon: '🌙' },
]
const BUDGET_OPTIONS = [
{ id: 'b50', label: '50100 $', min: 50, max: 100 },
{ id: 'b100', label: '100200 $', min: 100, max: 200 },
{ id: 'b200', label: '200350 $', min: 200, max: 350 },
{ id: 'b350', label: '350 $+', min: 350, max: null },
]
const TOTAL_STEPS = 5
const step = ref(1)
// ── Étape 1 : type de service ─────────────────────────────────────────────────
const selectedService = ref(null)
// ── Étape 2 : description du problème ────────────────────────────────────────
const selectedProblem = ref(null)
const description = ref('')
// ── Étape 3 : adresse ─────────────────────────────────────────────────────────
const address = ref(null)
const addressQuery = ref('')
const addressSuggestions = ref([])
const addressLoading = ref(false)
let debounceTimer = null
function onAddressInput (e) {
addressQuery.value = e.target.value
address.value = null
clearTimeout(debounceTimer)
if (addressQuery.value.length < 3) { addressSuggestions.value = []; return }
debounceTimer = setTimeout(fetchSuggestions, 350)
}
async function fetchSuggestions () {
addressLoading.value = true
try {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(addressQuery.value)}.json`
+ `?access_token=${MAPBOX_TOKEN}&country=CA&language=fr&limit=5`
const r = await fetch(url)
const d = await r.json()
addressSuggestions.value = d.features || []
} catch (_) { addressSuggestions.value = [] }
addressLoading.value = false
}
function selectAddress (f) {
address.value = f
addressQuery.value = f.place_name
addressSuggestions.value = []
}
// ── Étape 4 : 3 dates préférées ───────────────────────────────────────────────
const minDate = computed(() => new Date().toISOString().split('T')[0])
const preferredDates = ref([
{ date: '', timeSlots: [] }, // timeSlots = array of slot IDs (multi-select)
{ date: '', timeSlots: [] },
{ date: '', timeSlots: [] },
])
const urgency = ref('normal')
const budgetId = ref(null) // selected BUDGET_OPTIONS id
const activeDateIdx = ref(0) // which date card is open
function dateLabel (iso) {
if (!iso) return null
const d = new Date(iso + 'T12:00:00')
return d.toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
}
function toggleSlot (pd, slotId) {
if (pd.timeSlots.includes(slotId)) {
pd.timeSlots = pd.timeSlots.filter(s => s !== slotId)
} else {
pd.timeSlots = [...pd.timeSlots, slotId]
}
}
const validDates = computed(() => preferredDates.value.filter(d => d.date && d.timeSlots.length > 0))
// ── Étape 5 : contact ─────────────────────────────────────────────────────────
const contact = ref({ name: '', phone: '', email: '' })
// ── Validation ────────────────────────────────────────────────────────────────
const canNext = computed(() => {
if (step.value === 1) return !!selectedService.value
if (step.value === 2) return !!selectedProblem.value
if (step.value === 3) return !!address.value
if (step.value === 4) return validDates.value.length >= 1 && !!budgetId.value
if (step.value === 5) return contact.value.name.trim() && contact.value.phone.trim()
return false
})
function next () { if (canNext.value && step.value < TOTAL_STEPS) step.value++ }
function prev () { if (step.value > 1) step.value-- }
// ── Soumission ────────────────────────────────────────────────────────────────
const submitting = ref(false)
const confirmed = ref(false)
const refNumber = ref('')
async function submit () {
if (!canNext.value) return
submitting.value = true
try {
const result = await createServiceRequest({
service_type: selectedService.value,
problem_type: selectedProblem.value,
description: description.value,
address: address.value?.place_name || addressQuery.value,
coordinates: address.value?.center || [0, 0],
preferred_dates: validDates.value.map(d => ({
date: d.date,
time_slots: d.timeSlots,
time_slot: d.timeSlots[0] || '', // backward-compat primary slot
})),
urgency: urgency.value,
budget: BUDGET_OPTIONS.find(b => b.id === budgetId.value) || null,
contact: contact.value,
})
refNumber.value = result.ref
confirmed.value = true
} catch (e) {
console.error(e)
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="booking-root">
<!-- Confirmation -->
<div v-if="confirmed" class="confirm-screen">
<div class="confirm-card">
<div class="confirm-icon">✓</div>
<h2>Demande envoyée !</h2>
<p>Nos techniciens vont examiner votre demande et vous proposer une confirmation de rendez-vous.</p>
<div class="ref-box">
<span class="ref-label">Numéro de référence</span>
<span class="ref-val">{{ refNumber }}</span>
</div>
<p class="confirm-sub">Vous recevrez une confirmation par SMS ou courriel une fois une date confirmée.</p>
<button class="btn-primary" @click="$router.push('/')">Retour à l'accueil</button>
</div>
</div>
<!-- Wizard ───────────────────────────────────────────────────────────────── -->
<template v-else>
<!-- Header -->
<div class="booking-header">
<button class="btn-back" @click="step > 1 ? prev() : $router.push('/')" aria-label="Retour">←</button>
<div class="header-center">
<div class="header-logo">🌐</div>
<span>Demande de service</span>
</div>
<div class="step-pill">{{ step }}/{{ TOTAL_STEPS }}</div>
</div>
<!-- Progress bar -->
<div class="progress-bar"><div class="progress-fill" :style="{ width: (step / TOTAL_STEPS * 100) + '%' }"></div></div>
<!-- Content -->
<div class="booking-body">
<!-- ── Étape 1 : Sélection du service ── -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 1" key="s1" class="step-content">
<div class="step-title">Quel service avez-vous besoin ?</div>
<div class="service-grid">
<button v-for="s in SERVICES" :key="s.id"
class="service-card"
:class="{ selected: selectedService === s.id }"
@click="selectedService = s.id; selectedProblem = null">
<span class="svc-icon">{{ s.icon }}</span>
<span class="svc-label">{{ s.label }}</span>
<span class="svc-desc">{{ s.desc }}</span>
<span v-if="selectedService === s.id" class="svc-check">✓</span>
</button>
</div>
</div>
</transition>
<!-- ── Étape 2 : Description du problème ── -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 2" key="s2" class="step-content">
<div class="step-title">Quel est le problème ?</div>
<div class="service-label-chip">
{{ SERVICES.find(s => s.id === selectedService)?.icon }}
{{ SERVICES.find(s => s.id === selectedService)?.label }}
</div>
<div class="problem-list">
<button v-for="p in PROBLEMS[selectedService]" :key="p"
class="problem-item"
:class="{ selected: selectedProblem === p }"
@click="selectedProblem = p">
<span class="problem-radio">{{ selectedProblem === p ? '●' : '○' }}</span>
{{ p }}
</button>
</div>
<textarea class="textarea-desc" v-model="description"
placeholder="Détails supplémentaires (optionnel)…"
rows="3"></textarea>
</div>
</transition>
<!-- ── Étape 3 : Adresse ── -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 3" key="s3" class="step-content">
<div class="step-title">Adresse de l'intervention</div>
<div class="address-wrap">
<div class="input-group">
<span class="input-icon">📍</span>
<input class="addr-input" type="text"
:value="addressQuery"
@input="onAddressInput"
placeholder="Entrez votre adresse…"
autocomplete="off" />
<span v-if="addressLoading" class="input-spin">⟳</span>
</div>
<div v-if="addressSuggestions.length" class="suggestions">
<button v-for="f in addressSuggestions" :key="f.id"
class="suggestion-item"
@click="selectAddress(f)">
📍 {{ f.place_name }}
</button>
</div>
<div v-if="address" class="addr-confirmed">
✓ {{ address.place_name }}
</div>
</div>
</div>
</transition>
<!-- ── Étape 4 : 3 dates préférées ── -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 4" key="s4" class="step-content">
<div class="step-title">Disponibilités &amp; budget</div>
<p class="step-sub">Indiquez jusqu'à 3 dates et les plages horaires qui vous conviennent. Nous confirmerons la meilleure date.</p>
<!-- Urgence toggle -->
<div class="urgency-row">
<button class="urgency-btn" :class="{ active: urgency === 'normal' }" @click="urgency = 'normal'">Standard</button>
<button class="urgency-btn urgency-urgent" :class="{ active: urgency === 'urgent' }" @click="urgency = 'urgent'">Urgent 🚨</button>
</div>
<!-- 3 date cards -->
<div v-for="(pd, i) in preferredDates" :key="i" class="date-card"
:class="{ 'date-card-filled': pd.date && pd.timeSlots.length > 0, 'date-card-active': activeDateIdx === i }"
@click="activeDateIdx = i">
<div class="date-card-header">
<span class="date-priority">{{ ['1re', '2e', '3e'][i] }} priorité</span>
<span v-if="pd.date && pd.timeSlots.length > 0" class="date-summary">
{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}
</span>
<span v-else class="date-empty">Non définie</span>
<span class="date-toggle">{{ activeDateIdx === i ? '▲' : '▼' }}</span>
</div>
<div v-if="activeDateIdx === i" class="date-card-body">
<input type="date" class="date-input" v-model="pd.date" :min="minDate" />
<div class="slot-label">Plage(s) horaire</div>
<div class="slot-checks">
<label v-for="slot in TIME_SLOTS" :key="slot.id"
class="slot-check-row"
:class="{ checked: pd.timeSlots.includes(slot.id) }"
@click.stop="toggleSlot(pd, slot.id)">
<span class="slot-checkbox">
<span v-if="pd.timeSlots.includes(slot.id)" class="slot-checkbox-tick">✓</span>
</span>
<span class="slot-check-icon">{{ slot.icon }}</span>
<span class="slot-check-text">
<strong>{{ slot.label }}</strong>
<span>{{ slot.sub }}</span>
</span>
</label>
<label class="slot-check-row"
:class="{ checked: pd.timeSlots.includes('flexible') }"
@click.stop="pd.timeSlots = pd.timeSlots.includes('flexible') ? [] : ['flexible']">
<span class="slot-checkbox">
<span v-if="pd.timeSlots.includes('flexible')" class="slot-checkbox-tick">✓</span>
</span>
<span class="slot-check-icon">🕐</span>
<span class="slot-check-text">
<strong>Je suis flexible</strong>
<span>Au choix du technicien</span>
</span>
</label>
</div>
</div>
</div>
<p v-if="validDates.length === 0" class="hint-text">Remplissez au moins une date pour continuer.</p>
<p v-else class="hint-ok">✓ {{ validDates.length }} date{{ validDates.length > 1 ? 's' : '' }} sélectionnée{{ validDates.length > 1 ? 's' : '' }}</p>
<!-- Budget estimé -->
<div class="budget-section">
<div class="budget-title">Budget estimé</div>
<p class="budget-sub">Les techniciens soumettront leur tarif en fonction de votre budget.</p>
<div class="budget-grid">
<button v-for="b in BUDGET_OPTIONS" :key="b.id"
class="budget-btn"
:class="{ selected: budgetId === b.id }"
@click="budgetId = b.id">
{{ b.label }}
</button>
</div>
<p v-if="!budgetId" class="hint-text">Sélectionnez un budget pour continuer.</p>
</div>
</div>
</transition>
<!-- ── Étape 5 : Contact + résumé ── -->
<transition name="fade-up" mode="out-in">
<div v-if="step === 5" key="s5" class="step-content">
<div class="step-title">Vos coordonnées</div>
<div class="form-fields">
<div class="field-group">
<label>Nom complet *</label>
<input v-model="contact.name" type="text" placeholder="Jean Tremblay" class="field-input" />
</div>
<div class="field-group">
<label>Téléphone *</label>
<input v-model="contact.phone" type="tel" placeholder="514 555-0000" class="field-input" />
</div>
<div class="field-group">
<label>Courriel</label>
<input v-model="contact.email" type="email" placeholder="jean@exemple.com" class="field-input" />
</div>
</div>
<!-- Résumé -->
<div class="summary-box">
<div class="summary-row">
<span>Service</span>
<strong>{{ SERVICES.find(s => s.id === selectedService)?.label }}</strong>
</div>
<div class="summary-row">
<span>Problème</span>
<strong>{{ selectedProblem }}</strong>
</div>
<div class="summary-row">
<span>Adresse</span>
<strong>{{ address?.place_name || addressQuery }}</strong>
</div>
<div class="summary-row" v-for="(pd, i) in validDates" :key="i">
<span>Date {{ i + 1 }}</span>
<strong>{{ dateLabel(pd.date) }} · {{ pd.timeSlots.map(id => TIME_SLOTS.find(t => t.id === id)?.label).join(', ') }}</strong>
</div>
<div class="summary-row">
<span>Budget</span>
<strong>{{ BUDGET_OPTIONS.find(b => b.id === budgetId)?.label || '—' }}</strong>
</div>
<div class="summary-row" v-if="urgency === 'urgent'">
<span>Urgence</span>
<strong style="color:#f43f5e">🚨 Urgent</strong>
</div>
</div>
</div>
</transition>
</div><!-- /booking-body -->
<!-- Footer nav -->
<div class="booking-footer">
<button v-if="step < TOTAL_STEPS" class="btn-next" :disabled="!canNext" @click="next">
Continuer →
</button>
<button v-else class="btn-next btn-submit" :disabled="!canNext || submitting" @click="submit">
{{ submitting ? 'Envoi en cours' : 'Envoyer la demande ' }}
</button>
</div>
</template>
</div>
</template>
<style scoped>
/* ── Tokens ── */
.booking-root {
--accent: #6366f1;
--accent2: #818cf8;
--bg: #0f1117;
--surface: rgba(255,255,255,0.04);
--surface2: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.09);
--text: #f1f5f9;
--text2: #94a3b8;
--green: #10b981;
--red: #f43f5e;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
display: flex;
flex-direction: column;
max-width: 560px;
margin: 0 auto;
}
/* ── Header ── */
.booking-header {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem 0.75rem;
position: sticky; top: 0; z-index: 10;
background: rgba(15,17,23,0.9); backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
}
.btn-back { background: var(--surface); border: 1px solid var(--border); color: var(--text); border-radius: 8px; width: 36px; height: 36px; font-size: 1.1rem; cursor: pointer; display: flex; align-items: center; justify-content: center; }
.btn-back:hover { border-color: var(--accent); }
.header-center { display: flex; align-items: center; gap: 0.5rem; font-weight: 700; font-size: 0.95rem; }
.header-logo { font-size: 1.3rem; }
.step-pill { background: rgba(99,102,241,0.2); color: var(--accent2); border: 1px solid rgba(99,102,241,0.3); border-radius: 20px; padding: 0.2rem 0.65rem; font-size: 0.75rem; font-weight: 700; }
/* ── Progress ── */
.progress-bar { height: 3px; background: var(--border); }
.progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--accent2)); transition: width 0.4s ease; border-radius: 0 2px 2px 0; }
/* ── Body ── */
.booking-body { flex: 1; overflow-y: auto; padding: 1.5rem 1.25rem 6rem; }
.step-content { animation: fadeUp 0.25s ease; }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.step-title { font-size: 1.35rem; font-weight: 800; margin-bottom: 0.35rem; }
.step-sub { color: var(--text2); font-size: 0.85rem; margin-bottom: 1.25rem; line-height: 1.6; }
/* ── Service grid ── */
.service-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-top: 1.25rem; }
.service-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; padding: 1.1rem 0.9rem; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: 0.2rem; transition: all 0.18s; position: relative; }
.service-card:hover { border-color: rgba(99,102,241,0.4); background: var(--surface2); }
.service-card.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); box-shadow: 0 0 0 3px rgba(99,102,241,0.18); }
.svc-icon { font-size: 1.8rem; margin-bottom: 0.25rem; }
.svc-label { font-size: 0.95rem; font-weight: 700; }
.svc-desc { font-size: 0.72rem; color: var(--text2); }
.svc-check { position: absolute; top: 0.75rem; right: 0.75rem; background: var(--accent); color: white; border-radius: 50%; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; font-size: 0.7rem; font-weight: 800; }
/* ── Problem list ── */
.service-label-chip { display: inline-flex; align-items: center; gap: 0.4rem; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.25); color: var(--accent2); border-radius: 20px; padding: 0.3rem 0.85rem; font-size: 0.82rem; font-weight: 600; margin-bottom: 1.25rem; }
.problem-list { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.problem-item { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.88rem; display: flex; align-items: center; gap: 0.75rem; transition: all 0.15s; }
.problem-item:hover { border-color: rgba(99,102,241,0.35); }
.problem-item.selected { border-color: var(--accent); background: rgba(99,102,241,0.1); color: white; }
.problem-radio { font-size: 1rem; color: var(--accent); flex-shrink: 0; }
.textarea-desc { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.85rem 1rem; color: var(--text); font-size: 0.88rem; resize: vertical; font-family: inherit; box-sizing: border-box; }
.textarea-desc:focus { border-color: var(--accent); outline: none; }
/* ── Address ── */
.address-wrap { margin-top: 1rem; }
.input-group { position: relative; display: flex; align-items: center; }
.input-icon { position: absolute; left: 0.9rem; font-size: 1rem; pointer-events: none; }
.addr-input { width: 100%; background: var(--surface); border: 1.5px solid var(--border); border-radius: 12px; padding: 0.85rem 3rem 0.85rem 2.5rem; color: var(--text); font-size: 0.9rem; box-sizing: border-box; }
.addr-input:focus { border-color: var(--accent); outline: none; }
.input-spin { position: absolute; right: 0.9rem; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.suggestions { background: #1a1d27; border: 1px solid var(--border); border-radius: 12px; margin-top: 0.5rem; overflow: hidden; }
.suggestion-item { width: 100%; background: none; border: none; border-bottom: 1px solid var(--border); color: var(--text); padding: 0.75rem 1rem; cursor: pointer; text-align: left; font-size: 0.82rem; transition: background 0.12s; }
.suggestion-item:last-child { border-bottom: none; }
.suggestion-item:hover { background: var(--surface2); }
.addr-confirmed { margin-top: 0.85rem; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; color: var(--green); }
/* ── Dates ── */
.urgency-row { display: flex; gap: 0.5rem; margin-bottom: 1.25rem; }
.urgency-btn { flex: 1; background: var(--surface); border: 1.5px solid var(--border); color: var(--text2); border-radius: 10px; padding: 0.65rem; cursor: pointer; font-size: 0.85rem; font-weight: 600; transition: all 0.15s; }
.urgency-btn.active { border-color: var(--accent); background: rgba(99,102,241,0.12); color: var(--text); }
.urgency-urgent.active { border-color: var(--red); background: rgba(244,63,94,0.1); color: var(--red); }
.date-card { background: var(--surface); border: 1.5px solid var(--border); border-radius: 14px; margin-bottom: 0.75rem; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
.date-card:hover { border-color: rgba(99,102,241,0.35); }
.date-card-filled { border-color: rgba(16,185,129,0.4); }
.date-card-active { border-color: var(--accent); }
.date-card-header { display: flex; align-items: center; gap: 0.5rem; padding: 0.9rem 1rem; }
.date-priority { background: rgba(99,102,241,0.15); color: var(--accent2); border-radius: 6px; padding: 0.15rem 0.5rem; font-size: 0.7rem; font-weight: 700; flex-shrink: 0; }
.date-summary { flex: 1; font-size: 0.82rem; font-weight: 600; }
.date-empty { flex: 1; font-size: 0.82rem; color: var(--text2); font-style: italic; }
.date-toggle { color: var(--text2); font-size: 0.65rem; }
.date-card-body { padding: 0 1rem 1rem; border-top: 1px solid var(--border); }
.date-input { width: 100%; background: var(--surface2); border: 1px solid var(--border); border-radius: 8px; padding: 0.65rem 0.85rem; color: var(--text); font-size: 0.9rem; margin: 0.75rem 0; box-sizing: border-box; }
.date-input:focus { border-color: var(--accent); outline: none; }
.hint-text { font-size: 0.8rem; color: var(--text2); text-align: center; margin-top: 0.5rem; }
.hint-ok { font-size: 0.8rem; color: var(--green); text-align: center; margin-top: 0.5rem; font-weight: 600; }
/* ── Slot checkboxes ── */
.slot-label { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text2); margin-bottom: 0.5rem; }
.slot-checks { display: flex; flex-direction: column; gap: 0.4rem; }
.slot-check-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.75rem; border-radius: 10px; border: 1.5px solid var(--border); cursor: pointer; background: var(--surface2); transition: all 0.15s; user-select: none; }
.slot-check-row:hover { border-color: rgba(99,102,241,0.3); }
.slot-check-row.checked { border-color: var(--accent); background: rgba(99,102,241,0.1); }
.slot-checkbox { width: 20px; height: 20px; border-radius: 5px; border: 1.5px solid var(--border); flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: var(--surface); }
.slot-check-row.checked .slot-checkbox { background: var(--accent); border-color: var(--accent); }
.slot-checkbox-tick { color: white; font-size: 0.7rem; font-weight: 800; }
.slot-check-icon { font-size: 1.1rem; }
.slot-check-text { display: flex; flex-direction: column; gap: 0.05rem; }
.slot-check-text strong { font-size: 0.85rem; color: var(--text); }
.slot-check-text span { font-size: 0.7rem; color: var(--text2); }
/* ── Budget ── */
.budget-section { margin-top: 1.5rem; border-top: 1px solid var(--border); padding-top: 1.25rem; }
.budget-title { font-size: 1rem; font-weight: 800; margin-bottom: 0.25rem; }
.budget-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 0.85rem; }
.budget-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.budget-btn { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 0.5rem; cursor: pointer; color: var(--text); font-size: 0.9rem; font-weight: 700; transition: all 0.15s; }
.budget-btn:hover { border-color: rgba(99,102,241,0.35); }
.budget-btn.selected { border-color: var(--accent); background: rgba(99,102,241,0.12); color: #a5b4fc; }
/* ── Contact + résumé ── */
.form-fields { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 1.5rem; }
.field-group { display: flex; flex-direction: column; gap: 0.35rem; }
.field-group label { font-size: 0.78rem; font-weight: 700; color: var(--text2); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { background: var(--surface); border: 1.5px solid var(--border); border-radius: 10px; padding: 0.75rem 1rem; color: var(--text); font-size: 0.9rem; }
.field-input:focus { border-color: var(--accent); outline: none; }
.summary-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
.summary-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; padding: 0.7rem 1rem; border-bottom: 1px solid var(--border); font-size: 0.82rem; }
.summary-row:last-child { border-bottom: none; }
.summary-row span { color: var(--text2); flex-shrink: 0; }
.summary-row strong { text-align: right; }
/* ── Footer ── */
.booking-footer {
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; max-width: 560px; padding: 1rem 1.25rem;
background: linear-gradient(to top, var(--bg) 70%, transparent);
}
.btn-next { width: 100%; background: var(--accent); border: none; color: white; border-radius: 14px; padding: 1rem; font-size: 1rem; font-weight: 700; cursor: pointer; transition: opacity 0.15s; }
.btn-next:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-next:hover:not(:disabled) { opacity: 0.88; }
.btn-submit { background: linear-gradient(135deg, var(--accent), #a855f7); }
/* ── Confirmation ── */
.confirm-screen { min-height: 100dvh; display: flex; align-items: center; justify-content: center; padding: 2rem; }
.confirm-card { text-align: center; max-width: 380px; }
.confirm-icon { width: 72px; height: 72px; background: rgba(16,185,129,0.15); border: 2px solid var(--green); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 2rem; color: var(--green); margin: 0 auto 1.5rem; }
.confirm-card h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.75rem; }
.confirm-card p { color: var(--text2); line-height: 1.6; margin-bottom: 1.5rem; font-size: 0.9rem; }
.ref-box { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 1rem 1.5rem; margin-bottom: 1.25rem; display: flex; flex-direction: column; gap: 0.35rem; }
.ref-label { font-size: 0.72rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.06em; }
.ref-val { font-size: 1.5rem; font-weight: 800; color: var(--accent2); letter-spacing: 0.08em; }
.confirm-sub { font-size: 0.8rem; color: var(--text2); margin-bottom: 2rem; }
.btn-primary { background: var(--accent); border: none; color: white; border-radius: 12px; padding: 0.85rem 2rem; font-size: 0.95rem; font-weight: 700; cursor: pointer; }
/* ── Transitions ── */
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.22s ease; }
.fade-up-enter-from { opacity: 0; transform: translateY(12px); }
.fade-up-leave-to { opacity: 0; transform: translateY(-8px); }
</style>