gigafibre-fsm/apps/website/src/components/AvailabilityDialog.tsx
louispaulb 8e8b89c531 merge: import site-web-targo into apps/website/ (4 commits preserved)
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>
2026-03-28 08:09:15 -04:00

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>
);
}