gigafibre-fsm/apps/client/src/pages/CartPage.vue
louispaulb a8eeb1b77d refactor: major cleanup — remove dead dispatch app, commit all backend code, extract client composables
- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained)
- Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked)
- Commit services/docuseal + services/legacy-db docker-compose configs
- Extract client app composables: useOTP, useAddressSearch, catalog data, format utils
- Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines
- Clean hardcoded credentials from config.js fallback values
- Add client portal: catalog, cart, checkout, OTP verification, address search
- Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal
- Add ops composables: useBestTech, useConversations, usePermissions, useScanner
- Add field app: scanner composable, docker/nginx configs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:38:38 -04:00

302 lines
18 KiB
Vue

<template>
<q-page class="cart-page q-pa-md">
<div style="max-width:900px;margin:0 auto">
<div v-if="cart.items.length === 0" class="text-center q-pa-xl">
<q-icon name="shopping_cart" size="80px" color="grey-4" />
<div class="text-h5 text-grey-6 q-mt-lg">Votre panier est vide</div>
<q-btn color="primary" unelevated label="Voir le catalogue" class="q-mt-lg" @click="$router.push('/catalogue')" />
</div>
<template v-else>
<div class="row items-center q-mb-lg">
<q-btn flat round icon="arrow_back" @click="$router.push('/catalogue')" />
<div class="text-h5 text-weight-bold q-ml-sm">Mon panier</div>
<q-space />
<q-btn flat color="negative" label="Vider le panier" icon="delete_sweep" @click="confirmClear" />
</div>
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-7">
<!-- Cart items -->
<q-card flat bordered class="q-mb-md rounded-card">
<q-list separator>
<q-item v-for="(item, idx) in cart.items" :key="item.item_code" class="q-pa-md">
<q-item-section>
<q-item-label class="text-weight-bold">{{ item.item_name }}</q-item-label>
<q-item-label caption>{{ item.billing_type === 'Mensuel' ? 'Mensuel' : 'Achat unique' }}</q-item-label>
<q-item-label caption v-if="item.description" class="q-mt-xs">{{ item.description }}</q-item-label>
</q-item-section>
<q-item-section side class="row items-center q-gutter-sm" style="flex-direction:row">
<q-btn flat dense round icon="remove" size="sm" @click="cart.updateQty(idx, item.qty - 1)" />
<span class="text-weight-medium q-mx-xs">{{ item.qty }}</span>
<q-btn flat dense round icon="add" size="sm" @click="cart.updateQty(idx, item.qty + 1)" />
<span class="text-weight-bold q-ml-md" style="min-width:80px;text-align:right">{{ formatPrice(item.rate * item.qty) }}</span>
<q-btn flat dense round icon="close" color="negative" size="sm" class="q-ml-sm" @click="cart.removeItem(idx)" />
</q-item-section>
</q-item>
</q-list>
</q-card>
<!-- Installation scheduling -->
<q-card v-if="cart.requiresVisit" flat bordered class="q-mb-md rounded-card">
<q-card-section>
<div class="text-subtitle1 text-weight-bold q-mb-sm">
<q-icon name="calendar_month" class="q-mr-xs" /> Installation requise
</div>
<div class="text-body2 text-grey-7 q-mb-md">Certains articles nécessitent une visite d'installation.</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-6">
<q-input v-model="form.preferredDate" type="date" label="Date préférée" outlined dense :min="minDate" :rules="[v => !!v || 'Requis']" />
</div>
<div class="col-12 col-sm-6">
<q-select v-model="form.preferredSlot" :options="TIME_SLOTS" label="Plage horaire" outlined dense emit-value map-options />
</div>
</div>
</q-card-section>
</q-card>
<!-- OTP existing customer -->
<q-card flat bordered class="q-mb-md rounded-card">
<q-card-section>
<div class="row items-center q-mb-sm">
<q-icon name="person_search" size="22px" color="primary" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Vous êtes déjà client?</div>
</div>
<div class="text-body2 text-grey-7 q-mb-md">Entrez votre courriel ou téléphone pour associer cette commande à votre compte.</div>
<div v-if="!otp.verified.value">
<div v-if="otp.step.value === 'verify'" class="text-caption text-grey-7 q-mb-xs">
Code envoyé par {{ otp.channel.value === 'email' ? 'courriel' : 'SMS' }} au <strong>{{ otp.identifier.value }}</strong>
· <a class="cursor-pointer text-primary" style="text-decoration:none" @click="otp.reset()">Modifier</a>
</div>
<div class="row q-col-gutter-sm items-center">
<div class="col">
<q-input v-if="otp.step.value === 'idle'" v-model="otp.identifier.value" label="Courriel ou téléphone" outlined dense placeholder="ex: 514-555-1234 ou nom@exemple.com" @keyup.enter="otp.identifier.value.length >= 5 && otp.send()">
<template v-slot:prepend><q-icon name="person_search" size="20px" /></template>
</q-input>
<q-input v-else ref="otpInputRef" v-model="otp.code.value" label="Code de vérification" outlined dense type="tel" maxlength="6" placeholder="000000" input-class="text-weight-bold text-center" input-style="font-size:1.3rem;letter-spacing:8px" @keyup.enter="otp.code.value.length === 6 && otp.verify()">
<template v-slot:prepend><q-icon name="lock" size="20px" /></template>
</q-input>
</div>
<div class="col-auto">
<q-btn color="primary" unelevated
:label="otp.step.value === 'idle' ? 'Envoyer' : 'Valider'"
:icon="otp.step.value === 'idle' ? 'send' : 'check'"
:loading="otp.step.value === 'idle' ? otp.sending.value : otp.verifying.value"
:disable="otp.step.value === 'idle' ? (!otp.identifier.value || otp.identifier.value.length < 5) : otp.code.value.length !== 6"
@click="otp.step.value === 'idle' ? otp.send() : otp.verify()" />
</div>
</div>
<div v-if="otp.step.value === 'verify'" class="q-mt-xs">
<q-btn flat dense size="sm" color="grey-7" label="Renvoyer le code" icon="refresh" @click="otp.send()" :loading="otp.sending.value" no-caps />
</div>
<div v-if="otp.error.value" class="text-negative text-caption q-mt-xs">
<q-icon name="error" size="14px" /> {{ otp.error.value }}
</div>
</div>
<!-- Verified state -->
<div v-if="otp.verified.value" class="q-mt-sm">
<q-banner dense rounded class="bg-green-1 text-positive">
<template v-slot:avatar><q-icon name="verified_user" color="positive" /></template>
<strong>{{ form.name }}</strong> — compte vérifié
<template v-slot:action><q-btn flat dense label="Changer" @click="otp.reset()" /></template>
</q-banner>
<div v-if="otp.addresses.value.length" class="q-mt-md">
<div class="text-caption text-weight-bold q-mb-xs">Vos adresses :</div>
<q-list bordered separator class="rounded-borders">
<q-item v-for="(addr, i) in otp.addresses.value" :key="i" clickable v-ripple
@click="addr$.selectCustomerAddr(addr)"
:class="form.address === addr.address ? 'bg-blue-1' : ''">
<q-item-section avatar>
<q-icon :name="form.address === addr.address ? 'radio_button_checked' : 'radio_button_unchecked'" :color="form.address === addr.address ? 'primary' : 'grey-5'" />
</q-item-section>
<q-item-section>
<q-item-label>{{ addr.address }}</q-item-label>
<q-item-label caption>{{ addr.city }}{{ addr.postal_code ? ' — ' + addr.postal_code : '' }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div class="text-caption text-grey-6 q-mt-xs">Ou recherchez une nouvelle adresse ci-dessous</div>
</div>
</div>
<div v-if="otp.step.value === 'idle' && !otp.verified.value" class="text-caption text-grey-6 q-mt-sm">
Pas encore client? Continuez comme nouveau client ci-dessous.
</div>
</q-card-section>
</q-card>
<!-- Customer info -->
<q-card flat bordered class="q-mb-md rounded-card">
<q-card-section>
<div class="text-subtitle1 text-weight-bold q-mb-md"><q-icon name="person" class="q-mr-xs" /> Vos coordonnées</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-sm-6">
<q-input v-model="form.name" label="Nom complet" outlined dense :rules="[v => !!v || 'Requis']" />
</div>
<div class="col-12 col-sm-6">
<q-input v-model="form.phone" label="Téléphone" outlined dense mask="(###) ###-####" :rules="[v => !!v || 'Requis']" />
</div>
<div class="col-12">
<q-input v-model="form.email" label="Courriel" outlined dense type="email" :rules="[v => !!v || 'Requis', v => /.+@.+\..+/.test(v) || 'Courriel invalide']" />
</div>
<div class="col-12">
<q-input v-model="addr$.query.value" label="Adresse d'installation / livraison" outlined dense :loading="addr$.searching.value" @update:model-value="addr$.onInput" :rules="[() => !!form.address || 'Sélectionnez une adresse']">
<template v-slot:prepend><q-icon name="place" /></template>
<template v-slot:append><q-icon v-if="form.address" name="check_circle" color="positive" /></template>
</q-input>
<q-list v-if="addr$.results.value.length && !addr$.selected.value" bordered separator class="addr-dropdown">
<q-item v-for="(a, i) in addr$.results.value" :key="i" clickable v-ripple @click="addr$.selectResult(a)">
<q-item-section avatar><q-icon name="location_on" :color="a.fiber_available ? 'positive' : 'grey-5'" /></q-item-section>
<q-item-section>
<q-item-label>{{ a.adresse_formatee }}</q-item-label>
<q-item-label caption>{{ a.nom_municipalite }}{{ a.code_postal ? ' — ' + a.code_postal : '' }} <q-badge v-if="a.fiber_available" color="positive" label="Fibre" class="q-ml-xs" /></q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-if="addr$.selected.value && form.address" class="text-caption text-grey-7 q-mt-xs q-ml-sm">
<q-icon name="check" color="positive" size="14px" /> {{ form.address }}{{ form.city ? ', ' + form.city : '' }}{{ form.postalCode ? ' ' + form.postalCode : '' }}
</div>
</div>
</div>
</q-card-section>
</q-card>
</div>
<!-- Order summary -->
<div class="col-12 col-md-5">
<q-card flat bordered class="summary-card rounded-card" style="position:sticky;top:80px">
<q-card-section>
<div class="text-subtitle1 text-weight-bold q-mb-md">Sommaire de commande</div>
<div v-for="item in cart.items" :key="item.item_code" class="row justify-between q-mb-xs text-body2">
<span>{{ item.item_name }} x{{ item.qty }}</span>
<span>{{ formatPrice(item.rate * item.qty) }}</span>
</div>
<q-separator class="q-my-md" />
<div v-if="cart.recurringTotal > 0" class="row justify-between q-mb-xs">
<span class="text-body2">Mensuel</span>
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.recurringTotal) }}/mois</span>
</div>
<div v-if="cart.onetimeTotal > 0" class="row justify-between q-mb-xs">
<span class="text-body2">Achat unique</span>
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.onetimeTotal) }}</span>
</div>
<div class="row justify-between q-mb-xs">
<span class="text-body2">Sous-total</span>
<span class="text-body2 text-weight-medium">{{ formatPrice(cart.subtotal) }}</span>
</div>
<div class="row justify-between q-mb-xs">
<span class="text-body2 text-grey-6">TPS + TVQ (14,975%)</span>
<span class="text-body2 text-grey-6">{{ formatPrice(cart.taxAmount) }}</span>
</div>
<q-separator class="q-my-md" />
<div class="row justify-between">
<span class="text-subtitle1 text-weight-bold">Total</span>
<span class="text-subtitle1 text-weight-bold text-primary">{{ formatPrice(cart.grandTotal) }}</span>
</div>
</q-card-section>
<q-separator />
<q-card-section>
<q-checkbox v-model="form.acceptTerms" dense class="q-mb-md">
<template #default>
<span class="text-body2">
J'accepte les <a href="#" class="text-primary" @click.prevent>conditions d'utilisation</a>
et la <a href="#" class="text-primary" @click.prevent>politique de confidentialité</a>
</span>
</template>
</q-checkbox>
<div class="text-caption text-grey-6 q-mb-md">Aucun paiement requis maintenant. Vous recevrez votre première facture après l'activation du service.</div>
<q-btn color="primary" unelevated class="full-width" size="lg" label="Commander" icon="check_circle" :loading="submitting" :disable="!canSubmit" @click="handleSubmit" />
</q-card-section>
</q-card>
</div>
</div>
</template>
</div>
</q-page>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useCartStore } from 'src/stores/cart'
import { useCustomerStore } from 'src/stores/customer'
import { submitOrder } from 'src/api/catalog'
import { useOTP } from 'src/composables/useOTP'
import { useAddressSearch } from 'src/composables/useAddressSearch'
import { formatPrice, minBookingDate } from 'src/utils/format'
import { TIME_SLOTS } from 'src/data/catalog'
const $q = useQuasar()
const router = useRouter()
const cart = useCartStore()
const customer = useCustomerStore()
const submitting = ref(false)
const minDate = computed(() => minBookingDate())
const form = reactive({
name: '', phone: '', email: '', address: '', city: '', province: 'QC',
postalCode: '', latitude: null, longitude: null,
preferredDate: minBookingDate(), preferredSlot: 'AM',
acceptTerms: false, customer_id: null,
})
const addr$ = useAddressSearch(form)
const otp = useOTP(form, {
onVerified (result) {
if (result.addresses?.length) addr$.selectCustomerAddr(result.addresses[0])
$q.notify({ message: 'Compte vérifié!', color: 'positive', icon: 'verified_user' })
},
})
onMounted(() => {
if (customer.customerName) form.name = customer.customerName
if (customer.email) form.email = customer.email
})
const canSubmit = computed(() => {
const base = form.name && form.phone && form.email && form.address && form.acceptTerms
return cart.requiresVisit ? base && form.preferredDate : base
})
function confirmClear () {
$q.dialog({
title: 'Vider le panier', message: 'Voulez-vous vraiment retirer tous les articles?',
cancel: { label: 'Annuler', flat: true }, ok: { label: 'Vider', color: 'negative' },
}).onOk(() => cart.clearCart())
}
async function handleSubmit () {
submitting.value = true
try {
const payload = {
customer_id: form.customer_id || customer.customerId || null,
items: cart.items.map(({ item_code, item_name, qty, rate, billing_type, requires_visit, project_template_id }) =>
({ item_code, item_name, qty, rate, billing_type, requires_visit, project_template_id })),
contact: { name: form.name, phone: form.phone, email: form.email, address: form.address, city: form.city, province: form.province, postal_code: form.postalCode, latitude: form.latitude, longitude: form.longitude },
installation: cart.requiresVisit ? { preferred_date: form.preferredDate, preferred_slot: form.preferredSlot } : null,
totals: { subtotal: cart.subtotal, tax: cart.taxAmount, grand_total: cart.grandTotal, recurring_monthly: cart.recurringTotal, onetime: cart.onetimeTotal },
}
const result = await submitOrder(payload)
router.push({ name: 'order-success', query: { order: result.order_id }, state: { orderData: JSON.stringify({ ...payload, order_id: result.order_id }) } })
cart.clearCart()
} catch (e) {
$q.notify({ message: 'Erreur : ' + (e.message || 'Veuillez réessayer'), color: 'negative', icon: 'error' })
} finally { submitting.value = false }
}
</script>
<style lang="scss" scoped>
.cart-page { padding-bottom: 32px }
.rounded-card { border-radius: 12px }
.summary-card { background: #fafbff }
.addr-dropdown { position: relative; z-index: 10; background: #fff; box-shadow: 0 4px 16px rgba(0,0,0,.1); max-height: 280px; overflow-y: auto; border-radius: 0 0 8px 8px; margin-top: -8px }
</style>