gigafibre-fsm/apps/website-vue/src/components/AvailabilityDialog.vue
louispaulb 3c7f04870c portal: Vue 3 + Tailwind + shadcn-vue rewrite of www.gigafibre.ca (preview at /next)
Pixel-identical port of the React marketing site: Tailwind config, HSL theme
tokens, fonts (Plus Jakarta Sans / Space Grotesk) and custom utilities copied
verbatim; shadcn-vue (reka-ui) components; all 33 routes ported. Served at
www.gigafibre.ca/next (noindex) behind nginx, ahead of replacing the React site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00

414 lines
14 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from "vue";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Search,
MapPin,
CheckCircle2,
XCircle,
Loader2,
ArrowLeft,
Zap,
Mail,
Phone,
Send,
} from "lucide-vue-next";
import { z } from "zod";
// Address search via our own API (no Supabase dependency)
const API_BASE = import.meta.env.VITE_API_BASE || "";
const phoneClean = (v: string) => v.replace(/[\s\-().]/g, "");
const contactSchema = z.union([
z.string().trim().email().max(254),
z.string().trim().transform(phoneClean).pipe(
z.string().regex(/^\+?1?\d{10,11}$/, "Numéro invalide"),
),
]);
const CONTACT_COOLDOWN_MS = 5000;
interface AddressResult {
id: number;
address_full: string;
numero: string;
rue: string;
ville: string;
code_postal: string;
longitude: number;
latitude: number;
fiber_available: boolean;
zone_tarifaire: number;
max_speed: number;
similarity_score: number;
}
type Step = "search" | "result";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ "update:open": [value: boolean] }>();
const openModel = computed({
get: () => props.open,
set: (v: boolean) => emit("update:open", v),
});
const query = ref("");
const results = ref<AddressResult[]>([]);
const searching = ref(false);
const selected = ref<AddressResult | null>(null);
const step = ref<Step>("search");
const checking = ref(false);
const contactValue = ref("");
const contactSent = ref(false);
const sendingContact = ref(false);
const contactError = ref("");
let lastSubmit = 0;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
// Reset on close
watch(
() => props.open,
(open) => {
if (!open) {
setTimeout(() => {
query.value = "";
results.value = [];
selected.value = null;
step.value = "search";
contactValue.value = "";
contactSent.value = false;
}, 300);
}
},
);
// Cleanup debounce on unmount
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer);
});
const searchAddresses = async (term: string) => {
if (term.length < 3) {
results.value = [];
return;
}
searching.value = true;
try {
const res = await fetch(API_BASE + "/rpc/search_addresses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ search_term: term, result_limit: 8 }),
});
if (res.ok) {
const data = await res.json();
results.value = data as AddressResult[];
}
} finally {
searching.value = false;
}
};
const handleQueryChange = (value: string) => {
query.value = value;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => searchAddresses(value), 350);
};
const handleValidate = async (address: AddressResult) => {
selected.value = address;
checking.value = true;
step.value = "result";
// Log the search (fire and forget)
try {
console.log("[Search]", query.value, "→", address.address_full, address.fiber_available);
} catch (e) {
console.error("Log search error:", e);
}
// Small delay for UX
await new Promise((r) => setTimeout(r, 800));
checking.value = false;
};
const handleContactSubmit = async () => {
if (!contactValue.value.trim() || !selected.value) return;
// Rate limit
const now = Date.now();
if (now - lastSubmit < CONTACT_COOLDOWN_MS) {
contactError.value = "Veuillez patienter quelques secondes.";
return;
}
// Validate contact
const result = contactSchema.safeParse(contactValue.value.trim());
if (!result.success) {
contactError.value = "Veuillez entrer un courriel ou numéro de mobile valide.";
return;
}
contactError.value = "";
sendingContact.value = true;
lastSubmit = now;
const validContact = result.data;
// Send lead notification via Mailjet
try {
await fetch(API_BASE + "/rpc/lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contact: validContact,
address: selected.value.address_full,
fiber_available: selected.value.fiber_available,
max_speed: selected.value.max_speed || 0,
}),
});
} catch (e) {
console.error("Lead send error:", e);
}
sendingContact.value = false;
contactSent.value = true;
};
const speedLabel = (speed: number) => {
if (!speed || speed === 0) return "1.5 Gbit/s";
if (speed >= 1000) return `${(speed / 1000).toFixed(1)} Gbit/s`;
return `${speed} Mbit/s`;
};
const backToSearch = () => {
step.value = "search";
selected.value = null;
contactSent.value = false;
contactValue.value = "";
};
</script>
<template>
<Dialog v-model:open="openModel">
<DialogContent class="sm:max-w-lg max-h-[85vh] overflow-hidden flex flex-col top-[8%] translate-y-0 sm:top-[50%] sm:translate-y-[-50%]">
<DialogHeader>
<DialogTitle class="flex items-center gap-2 text-xl">
<Zap class="w-5 h-5 text-targo-green" />
Vérifier la disponibilité
</DialogTitle>
<DialogDescription>
Entrez votre adresse pour vérifier si la fibre optique est disponible
</DialogDescription>
</DialogHeader>
<Transition mode="out-in">
<div
v-if="step === 'search'"
key="search"
v-motion
:initial="{ opacity: 0, x: -20 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -20 }"
class="space-y-4 flex-1 overflow-hidden flex flex-col"
>
<!-- Search input -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Ex: 1933 Avenue Goulet, Montréal"
:model-value="query"
class="pl-10"
autofocus
@update:model-value="(v) => handleQueryChange(String(v))"
/>
<Loader2 v-if="searching" class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
</div>
<!-- Results list -->
<div class="flex-1 overflow-y-auto space-y-1 max-h-[50vh]">
<p v-if="results.length === 0 && query.length >= 3 && !searching" class="text-sm text-muted-foreground text-center py-8">
Aucune adresse trouvée
</p>
<button
v-for="addr in results"
:key="addr.id"
class="w-full text-left p-3 rounded-lg border border-border hover:border-targo-green hover:bg-targo-green/5 transition-all duration-200 group"
@click="handleValidate(addr)"
>
<div class="flex items-start gap-3">
<MapPin class="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-targo-green shrink-0" />
<div class="min-w-0">
<p class="text-sm font-medium truncate">
{{ addr.address_full }}
</p>
<p class="text-xs text-muted-foreground">
{{ addr.ville }} {{ addr.code_postal }}
</p>
</div>
<span v-if="addr.fiber_available" class="ml-auto shrink-0 text-[10px] font-bold uppercase tracking-wider text-targo-green bg-targo-green/10 px-2 py-0.5 rounded-full">
Fibre
</span>
</div>
</button>
</div>
</div>
<div
v-else-if="step === 'result' && selected"
key="result"
v-motion
:initial="{ opacity: 0, x: 20 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: 20 }"
class="space-y-6"
>
<div v-if="checking" class="flex flex-col items-center py-12 gap-4">
<Loader2 class="w-10 h-10 animate-spin text-targo-green" />
<p class="text-sm text-muted-foreground">
Vérification en cours…
</p>
</div>
<!-- ✅ AVAILABLE -->
<div v-else-if="selected.fiber_available" class="space-y-6">
<div class="text-center space-y-3">
<div class="w-16 h-16 rounded-full bg-targo-green/10 flex items-center justify-center mx-auto">
<CheckCircle2 class="w-8 h-8 text-targo-green" />
</div>
<h3 class="text-xl font-bold text-foreground">
Félicitations !
</h3>
<p class="text-muted-foreground text-sm">
Le service est disponible à votre adresse jusqu'à{{ " " }}
<span class="font-bold text-targo-green">
{{ speedLabel(selected.max_speed) }}
</span>
</p>
<div class="bg-primary/5 border border-primary/20 rounded-xl p-4 mt-2">
<p class="text-xs text-muted-foreground mb-1">Votre forfait</p>
<p class="text-2xl font-bold text-primary font-display">
À partir de 39<sup class="text-sm">,95</sup>$/mois
</p>
</div>
<p class="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
<MapPin class="inline w-3 h-3 mr-1" />
{{ selected.address_full }}
</p>
</div>
<div v-if="!contactSent" class="space-y-3">
<p class="text-sm font-medium text-center">
Inscrivez votre courriel ou numéro de mobile pour
démarrer votre abonnement
</p>
<div class="space-y-1">
<div class="flex gap-2">
<Input
placeholder="courriel ou mobile"
:model-value="contactValue"
class="flex-1"
:maxlength="254"
@update:model-value="(v) => { contactValue = String(v); contactError = ''; }"
/>
<Button
:disabled="!contactValue.trim() || sendingContact"
class="gradient-targo"
@click="handleContactSubmit"
>
<Loader2 v-if="sendingContact" class="w-4 h-4 animate-spin" />
<Send v-else class="w-4 h-4" />
</Button>
</div>
<p v-if="contactError" class="text-xs text-destructive">{{ contactError }}</p>
</div>
</div>
<div v-else class="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
<CheckCircle2 class="w-5 h-5 mx-auto mb-2" />
Merci ! Nous vous contacterons très bientôt.
</div>
</div>
<!-- ❌ NOT AVAILABLE -->
<div v-else class="space-y-6">
<div class="text-center space-y-3">
<div class="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto">
<XCircle class="w-8 h-8 text-destructive" />
</div>
<h3 class="text-xl font-bold text-foreground">
Adresse non confirmée
</h3>
<p class="text-muted-foreground text-sm">
Nous n'arrivons pas à confirmer la disponibilité pour
votre adresse.
</p>
<p class="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
<MapPin class="inline w-3 h-3 mr-1" />
{{ selected.address_full }}
</p>
</div>
<div class="space-y-4">
<div class="flex items-center justify-center gap-4 text-sm">
<a
href="tel:18558882746"
class="flex items-center gap-2 text-targo-green hover:underline font-medium"
>
<Phone class="w-4 h-4" />
1-855-888-2746
</a>
</div>
<div class="text-center text-xs text-muted-foreground">
ou inscrivez votre courriel et nous vous contacterons
</div>
<div v-if="!contactSent" class="space-y-1">
<div class="flex gap-2">
<Input
placeholder="votre courriel"
:model-value="contactValue"
class="flex-1"
:maxlength="254"
@update:model-value="(v) => { contactValue = String(v); contactError = ''; }"
/>
<Button
:disabled="!contactValue.trim() || sendingContact"
variant="outline"
class="border-targo-green text-targo-green hover:bg-targo-green/5"
@click="handleContactSubmit"
>
<Loader2 v-if="sendingContact" class="w-4 h-4 animate-spin" />
<Mail v-else class="w-4 h-4" />
</Button>
</div>
<p v-if="contactError" class="text-xs text-destructive">{{ contactError }}</p>
</div>
<div v-else class="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
<CheckCircle2 class="w-5 h-5 mx-auto mb-2" />
Merci ! Nous vous contacterons très bientôt.
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
class="w-full"
@click="backToSearch"
>
<ArrowLeft class="w-4 h-4 mr-2" />
Rechercher une autre adresse
</Button>
</div>
</Transition>
</DialogContent>
</Dialog>
</template>