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>
584 lines
30 KiB
Vue
584 lines
30 KiB
Vue
<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: '8h–12h', icon: '🌅' },
|
||
{ id: 'afternoon', label: 'Après-midi', sub: '12h–17h', icon: '☀️' },
|
||
{ id: 'evening', label: 'Soir', sub: '17h–20h', icon: '🌙' },
|
||
]
|
||
|
||
const BUDGET_OPTIONS = [
|
||
{ id: 'b50', label: '50–100 $', min: 50, max: 100 },
|
||
{ id: 'b100', label: '100–200 $', min: 100, max: 200 },
|
||
{ id: 'b200', label: '200–350 $', 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 & 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>
|