Integrates www.gigafibre.ca (React/Vite) into the monorepo. Full git history accessible via `git log -- apps/website/`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
431 lines
16 KiB
TypeScript
431 lines
16 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
// Address search via our own API (no Supabase dependency)
|
|
const API_BASE = import.meta.env.VITE_API_BASE || '';
|
|
import {
|
|
Search,
|
|
MapPin,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
ArrowLeft,
|
|
Zap,
|
|
Mail,
|
|
Phone,
|
|
Send,
|
|
} from "lucide-react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { z } from "zod";
|
|
|
|
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";
|
|
|
|
export function AvailabilityDialog({
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<AddressResult[]>([]);
|
|
const [searching, setSearching] = useState(false);
|
|
const [selected, setSelected] = useState<AddressResult | null>(null);
|
|
const [step, setStep] = useState<Step>("search");
|
|
const [checking, setChecking] = useState(false);
|
|
const [contactValue, setContactValue] = useState("");
|
|
const [contactSent, setContactSent] = useState(false);
|
|
const [sendingContact, setSendingContact] = useState(false);
|
|
const [contactError, setContactError] = useState("");
|
|
const lastSubmitRef = useRef<number>(0);
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
// Reset on close
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setTimeout(() => {
|
|
setQuery("");
|
|
setResults([]);
|
|
setSelected(null);
|
|
setStep("search");
|
|
setContactValue("");
|
|
setContactSent(false);
|
|
}, 300);
|
|
}
|
|
}, [open]);
|
|
|
|
// Cleanup debounce on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const searchAddresses = useCallback(async (term: string) => {
|
|
if (term.length < 3) {
|
|
setResults([]);
|
|
return;
|
|
}
|
|
setSearching(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();
|
|
setResults(data as AddressResult[]);
|
|
}
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleQueryChange = (value: string) => {
|
|
setQuery(value);
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => searchAddresses(value), 350);
|
|
};
|
|
|
|
const handleValidate = async (address: AddressResult) => {
|
|
setSelected(address);
|
|
setChecking(true);
|
|
setStep("result");
|
|
|
|
// Log the search (fire and forget)
|
|
try {
|
|
console.log('[Search]', query, '→', 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));
|
|
setChecking(false);
|
|
};
|
|
|
|
const handleContactSubmit = async () => {
|
|
if (!contactValue.trim() || !selected) return;
|
|
|
|
// Rate limit
|
|
const now = Date.now();
|
|
if (now - lastSubmitRef.current < CONTACT_COOLDOWN_MS) {
|
|
setContactError("Veuillez patienter quelques secondes.");
|
|
return;
|
|
}
|
|
|
|
// Validate contact
|
|
const result = contactSchema.safeParse(contactValue.trim());
|
|
if (!result.success) {
|
|
setContactError("Veuillez entrer un courriel ou numéro de mobile valide.");
|
|
return;
|
|
}
|
|
setContactError("");
|
|
setSendingContact(true);
|
|
lastSubmitRef.current = now;
|
|
|
|
const validContact = result.data;
|
|
const isEmail = validContact.includes("@");
|
|
|
|
// 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.address_full,
|
|
fiber_available: selected.fiber_available,
|
|
max_speed: selected.max_speed || 0,
|
|
}),
|
|
});
|
|
} catch (e) { console.error('Lead send error:', e); }
|
|
|
|
setSendingContact(false);
|
|
setContactSent(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`;
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="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 className="flex items-center gap-2 text-xl">
|
|
<Zap className="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>
|
|
|
|
<AnimatePresence mode="wait">
|
|
{step === "search" && (
|
|
<motion.div
|
|
key="search"
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
className="space-y-4 flex-1 overflow-hidden flex flex-col"
|
|
>
|
|
{/* Search input */}
|
|
<div className="relative">
|
|
<Search className="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"
|
|
value={query}
|
|
onChange={(e) => handleQueryChange(e.target.value)}
|
|
className="pl-10"
|
|
autoFocus
|
|
/>
|
|
{searching && (
|
|
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Results list */}
|
|
<div className="flex-1 overflow-y-auto space-y-1 max-h-[50vh]">
|
|
{results.length === 0 && query.length >= 3 && !searching && (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
Aucune adresse trouvée
|
|
</p>
|
|
)}
|
|
{results.map((addr) => (
|
|
<button
|
|
key={addr.id}
|
|
onClick={() => handleValidate(addr)}
|
|
className="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"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<MapPin className="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-targo-green shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{addr.address_full}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{addr.ville} {addr.code_postal}
|
|
</p>
|
|
</div>
|
|
{addr.fiber_available && (
|
|
<span className="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>
|
|
</motion.div>
|
|
)}
|
|
|
|
{step === "result" && selected && (
|
|
<motion.div
|
|
key="result"
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: 20 }}
|
|
className="space-y-6"
|
|
>
|
|
{checking ? (
|
|
<div className="flex flex-col items-center py-12 gap-4">
|
|
<Loader2 className="w-10 h-10 animate-spin text-targo-green" />
|
|
<p className="text-sm text-muted-foreground">
|
|
Vérification en cours…
|
|
</p>
|
|
</div>
|
|
) : selected.fiber_available ? (
|
|
/* ✅ AVAILABLE */
|
|
<div className="space-y-6">
|
|
<div className="text-center space-y-3">
|
|
<div className="w-16 h-16 rounded-full bg-targo-green/10 flex items-center justify-center mx-auto">
|
|
<CheckCircle2 className="w-8 h-8 text-targo-green" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-foreground">
|
|
Félicitations !
|
|
</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
Le service est disponible à votre adresse jusqu'à{" "}
|
|
<span className="font-bold text-targo-green">
|
|
{speedLabel(selected.max_speed)}
|
|
</span>
|
|
</p>
|
|
<div className="bg-primary/5 border border-primary/20 rounded-xl p-4 mt-2">
|
|
<p className="text-xs text-muted-foreground mb-1">Votre forfait</p>
|
|
<p className="text-2xl font-bold text-primary font-display">
|
|
À partir de 39<sup className="text-sm">,95</sup>$/mois
|
|
</p>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
|
|
<MapPin className="inline w-3 h-3 mr-1" />
|
|
{selected.address_full}
|
|
</p>
|
|
</div>
|
|
|
|
{!contactSent ? (
|
|
<div className="space-y-3">
|
|
<p className="text-sm font-medium text-center">
|
|
Inscrivez votre courriel ou numéro de mobile pour
|
|
démarrer votre abonnement
|
|
</p>
|
|
<div className="space-y-1">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="courriel ou mobile"
|
|
value={contactValue}
|
|
onChange={(e) => { setContactValue(e.target.value); setContactError(""); }}
|
|
className="flex-1"
|
|
maxLength={254}
|
|
/>
|
|
<Button
|
|
onClick={handleContactSubmit}
|
|
disabled={!contactValue.trim() || sendingContact}
|
|
className="gradient-targo"
|
|
>
|
|
{sendingContact ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Send className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{contactError && <p className="text-xs text-destructive">{contactError}</p>}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
|
|
<CheckCircle2 className="w-5 h-5 mx-auto mb-2" />
|
|
Merci ! Nous vous contacterons très bientôt.
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* ❌ NOT AVAILABLE */
|
|
<div className="space-y-6">
|
|
<div className="text-center space-y-3">
|
|
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto">
|
|
<XCircle className="w-8 h-8 text-destructive" />
|
|
</div>
|
|
<h3 className="text-xl font-bold text-foreground">
|
|
Adresse non confirmée
|
|
</h3>
|
|
<p className="text-muted-foreground text-sm">
|
|
Nous n'arrivons pas à confirmer la disponibilité pour
|
|
votre adresse.
|
|
</p>
|
|
<p className="text-xs text-muted-foreground bg-muted/50 rounded-lg p-3">
|
|
<MapPin className="inline w-3 h-3 mr-1" />
|
|
{selected.address_full}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-center gap-4 text-sm">
|
|
<a
|
|
href="tel:18558882746"
|
|
className="flex items-center gap-2 text-targo-green hover:underline font-medium"
|
|
>
|
|
<Phone className="w-4 h-4" />
|
|
1-855-888-2746
|
|
</a>
|
|
</div>
|
|
|
|
<div className="text-center text-xs text-muted-foreground">
|
|
ou inscrivez votre courriel et nous vous contacterons
|
|
</div>
|
|
|
|
{!contactSent ? (
|
|
<div className="space-y-1">
|
|
<div className="flex gap-2">
|
|
<Input
|
|
placeholder="votre courriel"
|
|
value={contactValue}
|
|
onChange={(e) => { setContactValue(e.target.value); setContactError(""); }}
|
|
className="flex-1"
|
|
maxLength={254}
|
|
/>
|
|
<Button
|
|
onClick={handleContactSubmit}
|
|
disabled={!contactValue.trim() || sendingContact}
|
|
variant="outline"
|
|
className="border-targo-green text-targo-green hover:bg-targo-green/5"
|
|
>
|
|
{sendingContact ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Mail className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{contactError && <p className="text-xs text-destructive">{contactError}</p>}
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-sm text-targo-green font-medium bg-targo-green/10 rounded-lg p-4">
|
|
<CheckCircle2 className="w-5 h-5 mx-auto mb-2" />
|
|
Merci ! Nous vous contacterons très bientôt.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setStep("search");
|
|
setSelected(null);
|
|
setContactSent(false);
|
|
setContactValue("");
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
|
Rechercher une autre adresse
|
|
</Button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|