349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||
import dynamic from "next/dynamic";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Switch } from "@/components/ui/switch";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Slider } from "@/components/ui/slider";
|
||
import { CruiserProfileView } from "@/components/CruiserProfileView";
|
||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||
import { Filter, X, Locate } from "lucide-react";
|
||
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
|
||
import { KINK_OPTIONS } from "@/types";
|
||
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
||
import { useOpenIdVerification, useAccess } from "@/components/Providers";
|
||
import { distanceMiles } from "@/lib/profile-stats";
|
||
|
||
import "leaflet/dist/leaflet.css";
|
||
|
||
const MapContainer = dynamic(
|
||
() => import("react-leaflet").then((m) => m.MapContainer),
|
||
{ ssr: false }
|
||
);
|
||
const TileLayer = dynamic(
|
||
() => import("react-leaflet").then((m) => m.TileLayer),
|
||
{ ssr: false }
|
||
);
|
||
const Marker = dynamic(
|
||
() => import("react-leaflet").then((m) => m.Marker),
|
||
{ ssr: false }
|
||
);
|
||
|
||
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
|
||
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
|
||
const DARK_TILES =
|
||
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
|
||
|
||
interface MapProps {
|
||
vanillaMode: boolean;
|
||
onActiveCountChange?: (count: number) => void;
|
||
}
|
||
|
||
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
||
const openIdVerification = useOpenIdVerification();
|
||
const { capabilities, blurExplicit } = useAccess();
|
||
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
|
||
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
|
||
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
||
const [showFilters, setShowFilters] = useState(false);
|
||
const [leafletReady, setLeafletReady] = useState(false);
|
||
const [iconFactory, setIconFactory] = useState<{
|
||
createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon;
|
||
createYou: () => import("leaflet").DivIcon;
|
||
createSpot: (category: string, count: number) => import("leaflet").DivIcon;
|
||
} | null>(null);
|
||
|
||
const [filters, setFilters] = useState<MapFilters>({
|
||
ageMin: 18,
|
||
ageMax: 65,
|
||
positions: [],
|
||
kinks: [],
|
||
onPrep: null,
|
||
status: [],
|
||
onlineOnly: true,
|
||
});
|
||
|
||
useEffect(() => {
|
||
import("leaflet").then((L) => {
|
||
const createPhoto = (p: Profile, blur: boolean) => {
|
||
const url =
|
||
p.avatar_url ||
|
||
"https://placehold.co/96x96/0a1628/3b82f6?text=?";
|
||
const dist =
|
||
p.distance_miles ??
|
||
(p.lat && p.lng
|
||
? distanceMiles(DEFAULT_LAT, DEFAULT_LNG, p.lat, p.lng)
|
||
: 1);
|
||
const distLabel =
|
||
dist < 1 ? dist.toFixed(1) : String(Math.max(1, Math.round(dist)));
|
||
const blurStyle = blur ? "filter:blur(6px);" : "";
|
||
const safeUrl = url.replace(/'/g, "%27");
|
||
|
||
return L.divIcon({
|
||
className: "sniffies-marker-wrap",
|
||
html: `<div class="sniffies-marker">
|
||
<div class="sniffies-avatar-ring ${p.status !== "offline" ? "sniffies-online" : ""}">
|
||
<div class="sniffies-avatar" style="background-image:url('${safeUrl}');${blurStyle}"></div>
|
||
</div>
|
||
<div class="sniffies-dist-badge">${distLabel}</div>
|
||
</div>`,
|
||
iconSize: [52, 58],
|
||
iconAnchor: [26, 29],
|
||
});
|
||
};
|
||
|
||
const createYou = () =>
|
||
L.divIcon({
|
||
className: "sniffies-you-wrap",
|
||
html: `<div class="sniffies-you">
|
||
<div class="sniffies-you-dot"></div>
|
||
<span class="sniffies-you-label">You</span>
|
||
</div>`,
|
||
iconSize: [48, 56],
|
||
iconAnchor: [24, 28],
|
||
});
|
||
|
||
const createSpot = (category: string, count: number) => {
|
||
const icons: Record<string, string> = {
|
||
club: "🍸",
|
||
gym: "💪",
|
||
beach: "🏖",
|
||
park: "🌳",
|
||
bookstore: "📚",
|
||
restroom: "🚻",
|
||
other: "📍",
|
||
};
|
||
const emoji = icons[category] || "📍";
|
||
return L.divIcon({
|
||
className: "sniffies-spot-wrap",
|
||
html: `<div class="sniffies-spot">
|
||
<span>${emoji}</span>
|
||
${count > 0 ? `<div class="sniffies-spot-count">${count}</div>` : ""}
|
||
</div>`,
|
||
iconSize: [36, 36],
|
||
iconAnchor: [18, 18],
|
||
});
|
||
};
|
||
|
||
setIconFactory({ createPhoto, createYou, createSpot });
|
||
setLeafletReady(true);
|
||
});
|
||
}, []);
|
||
|
||
const filteredProfiles = useMemo(() => {
|
||
return profiles.filter((p) => {
|
||
if (filters.onlineOnly && p.status === "offline") return false;
|
||
if (p.age && (p.age < filters.ageMin || p.age > filters.ageMax)) return false;
|
||
if (filters.positions.length && p.position && !filters.positions.includes(p.position))
|
||
return false;
|
||
if (filters.kinks.length && !filters.kinks.some((k) => p.kinks.includes(k)))
|
||
return false;
|
||
if (filters.onPrep === true && !p.on_prep) return false;
|
||
if (filters.status.length && !filters.status.includes(p.status)) return false;
|
||
return true;
|
||
});
|
||
}, [profiles, filters]);
|
||
|
||
useEffect(() => {
|
||
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
|
||
}, [filteredProfiles, onActiveCountChange]);
|
||
|
||
const handleChat = useCallback((profile: Profile) => {
|
||
if (!capabilities.canSendMessages) return;
|
||
window.location.href = `/chat?user=${profile.id}`;
|
||
}, [capabilities.canSendMessages]);
|
||
|
||
const togglePosition = (pos: Position) => {
|
||
setFilters((f) => ({
|
||
...f,
|
||
positions: f.positions.includes(pos)
|
||
? f.positions.filter((p) => p !== pos)
|
||
: [...f.positions, pos],
|
||
}));
|
||
};
|
||
|
||
const toggleKink = (kink: string) => {
|
||
setFilters((f) => ({
|
||
...f,
|
||
kinks: f.kinks.includes(kink)
|
||
? f.kinks.filter((k) => k !== kink)
|
||
: [...f.kinks, kink],
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<div className="relative h-full w-full bg-[#0a1628]">
|
||
{/* Minimal top controls — Sniffies style */}
|
||
<div className="absolute top-2 left-2 right-2 z-[1000] flex gap-2 items-start pointer-events-none">
|
||
<div className="pointer-events-auto flex gap-2 flex-wrap">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => setShowFilters(!showFilters)}
|
||
className="shadow-lg bg-[#0a1628]/90 border-white/10 text-white h-9"
|
||
>
|
||
<Filter className="h-4 w-4" />
|
||
</Button>
|
||
<Badge
|
||
variant="online"
|
||
className="shadow-lg self-center bg-blue-500/20 text-blue-300 border-blue-500/30"
|
||
>
|
||
{filteredProfiles.filter((p) => p.status !== "offline").length} cruising
|
||
</Badge>
|
||
</div>
|
||
{!capabilities.canSendMessages && (
|
||
<div className="pointer-events-auto flex-1 max-w-xs ml-auto hidden sm:block">
|
||
<GuestUpgradeBanner reason="general" compact />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{showFilters && (
|
||
<div className="absolute top-12 left-2 z-[1000] w-72 rounded-xl border border-white/10 bg-[#0a1628]/98 backdrop-blur-md p-4 shadow-2xl space-y-4 max-h-[70vh] overflow-y-auto">
|
||
<div className="flex items-center justify-between">
|
||
<span className="font-semibold text-sm text-white">Filters</span>
|
||
<Button variant="ghost" size="icon" onClick={() => setShowFilters(false)}>
|
||
<X className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs text-white/70">
|
||
Age: {filters.ageMin}–{filters.ageMax}
|
||
</Label>
|
||
<Slider
|
||
min={18}
|
||
max={80}
|
||
step={1}
|
||
value={[filters.ageMin, filters.ageMax]}
|
||
onValueChange={([min, max]) =>
|
||
setFilters((f) => ({ ...f, ageMin: min, ageMax: max }))
|
||
}
|
||
className="mt-2"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs mb-2 block text-white/70">Position</Label>
|
||
<div className="flex flex-wrap gap-1">
|
||
{(["top", "bottom", "vers", "side"] as Position[]).map((pos) => (
|
||
<Badge
|
||
key={pos}
|
||
variant={filters.positions.includes(pos) ? pos : "outline"}
|
||
className="cursor-pointer uppercase"
|
||
onClick={() => togglePosition(pos)}
|
||
>
|
||
{pos}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs mb-2 block text-white/70">Into</Label>
|
||
<div className="flex flex-wrap gap-1 max-h-24 overflow-y-auto">
|
||
{KINK_OPTIONS.slice(0, 12).map((k) => (
|
||
<Badge
|
||
key={k}
|
||
variant={filters.kinks.includes(k) ? "default" : "outline"}
|
||
className="cursor-pointer text-[10px]"
|
||
onClick={() => toggleKink(k)}
|
||
>
|
||
{k}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-xs text-white/70">On PrEP only</Label>
|
||
<Switch
|
||
checked={filters.onPrep === true}
|
||
onCheckedChange={(v) =>
|
||
setFilters((f) => ({ ...f, onPrep: v ? true : null }))
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between">
|
||
<Label className="text-xs text-white/70">Online only</Label>
|
||
<Switch
|
||
checked={filters.onlineOnly}
|
||
onCheckedChange={(v) =>
|
||
setFilters((f) => ({ ...f, onlineOnly: v }))
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Guest banner mobile */}
|
||
{!capabilities.canSendMessages && (
|
||
<div className="sm:hidden absolute top-12 left-2 right-2 z-[999]">
|
||
<GuestUpgradeBanner reason="general" compact />
|
||
</div>
|
||
)}
|
||
|
||
{leafletReady && iconFactory && (
|
||
<MapContainer
|
||
center={[DEFAULT_LAT, DEFAULT_LNG]}
|
||
zoom={15}
|
||
className="h-full w-full sniffies-map"
|
||
zoomControl={false}
|
||
>
|
||
<TileLayer
|
||
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||
url={DARK_TILES}
|
||
/>
|
||
|
||
<Marker
|
||
position={[DEFAULT_LAT, DEFAULT_LNG]}
|
||
icon={iconFactory.createYou()}
|
||
zIndexOffset={1000}
|
||
/>
|
||
|
||
{filteredProfiles.map((p) =>
|
||
p.lat && p.lng ? (
|
||
<Marker
|
||
key={p.id}
|
||
position={[p.lat, p.lng]}
|
||
icon={iconFactory.createPhoto(p, blurExplicit)}
|
||
eventHandlers={{
|
||
click: () => setSelectedProfile(p),
|
||
}}
|
||
/>
|
||
) : null
|
||
)}
|
||
|
||
{spots.map((s) => (
|
||
<Marker
|
||
key={s.id}
|
||
position={[s.lat, s.lng]}
|
||
icon={iconFactory.createSpot(s.category, s.active_count)}
|
||
eventHandlers={{
|
||
click: () => {
|
||
/* spot detail future */
|
||
},
|
||
}}
|
||
/>
|
||
))}
|
||
</MapContainer>
|
||
)}
|
||
|
||
{/* Recenter */}
|
||
<button
|
||
type="button"
|
||
className="absolute bottom-20 right-3 z-[1000] p-3 rounded-full bg-[#0a1628]/90 border border-white/15 text-blue-400 shadow-lg touch-manipulation md:bottom-6"
|
||
aria-label="Recenter map"
|
||
>
|
||
<Locate className="h-5 w-5" />
|
||
</button>
|
||
|
||
<CruiserProfileView
|
||
profile={selectedProfile}
|
||
open={!!selectedProfile}
|
||
onClose={() => setSelectedProfile(null)}
|
||
onChat={handleChat}
|
||
vanillaMode={vanillaMode}
|
||
onVerifyClick={openIdVerification}
|
||
/>
|
||
</div>
|
||
);
|
||
} |