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>
414 lines
14 KiB
Vue
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>
|