feat: Sniffies-style map+profiles, guest age-verify without ID, limited access

This commit is contained in:
Ryan Salazar
2026-06-26 21:41:50 -04:00
parent 448035ad60
commit 052cd12aca
18 changed files with 1268 additions and 251 deletions

View File

@@ -7,18 +7,16 @@ 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 { CruiserCard } from "@/components/CruiserCard";
import { ProfilePopup } from "@/components/ProfilePopup";
import { Filter, Users, X } from "lucide-react";
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, POSITION_COLORS } from "@/types";
import { KINK_OPTIONS } from "@/types";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import { useOpenIdVerification } from "@/components/Providers";
import { PinkPulsePartner } from "@/components/PinkPulsePartner";
import { useOpenIdVerification, useAccess } from "@/components/Providers";
import { distanceMiles } from "@/lib/profile-stats";
import "leaflet/dist/leaflet.css";
import "leaflet.markercluster/dist/MarkerCluster.css";
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
const MapContainer = dynamic(
() => import("react-leaflet").then((m) => m.MapContainer),
@@ -32,17 +30,11 @@ const Marker = dynamic(
() => import("react-leaflet").then((m) => m.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import("react-leaflet").then((m) => m.Popup),
{ ssr: false }
);
const MarkerClusterGroup = dynamic(
() => import("react-leaflet-cluster"),
{ 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;
@@ -51,15 +43,16 @@ interface MapProps {
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const openIdVerification = useOpenIdVerification();
const [profiles, setProfiles] = useState<Profile[]>(MOCK_PROFILES);
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 [showSidebar, setShowSidebar] = useState(true);
const [leafletReady, setLeafletReady] = useState(false);
const [iconFactory, setIconFactory] = useState<{
createUser: (position: string | null, status: string) => import("leaflet").DivIcon;
createSpot: (category: string) => import("leaflet").DivIcon;
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>({
@@ -74,46 +67,67 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
useEffect(() => {
import("leaflet").then((L) => {
const createUserIcon = (position: string | null, status: string) => {
const color = position
? POSITION_COLORS[position as Position] || "#ff2d6b"
: "#ff2d6b";
const pulse = status !== "offline" ? "animation:pulse 2s infinite;" : "";
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: "custom-marker",
html: `<div style="width:36px;height:36px;border-radius:50%;background:${color};border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;${pulse}">
<svg width="18" height="18" viewBox="0 0 24 24" fill="white"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
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],
});
};
const createSpotIcon = (category: string) => {
const colors: Record<string, string> = {
park: "#22c55e",
beach: "#3b82f6",
gym: "#f97316",
restroom: "#a855f7",
bookstore: "#ec4899",
club: "#ff2d6b",
other: "#6b7280",
};
const color = colors[category] || "#6b7280";
return L.divIcon({
className: "spot-marker",
html: `<div style="width:28px;height:28px;border-radius:4px;background:${color};border:2px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="white"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
};
setIconFactory({
createUser: createUserIcon,
createSpot: createSpotIcon,
});
setIconFactory({ createPhoto, createYou, createSpot });
setLeafletReady(true);
});
}, []);
@@ -137,8 +151,9 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
}, [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) => ({
@@ -159,44 +174,44 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
};
return (
<div className="relative h-full w-full">
{/* Filter bar */}
<div className="absolute top-2 left-2 right-2 z-[1000] flex gap-2 flex-wrap">
<Button
variant="secondary"
size="sm"
onClick={() => setShowFilters(!showFilters)}
className="shadow-lg"
>
<Filter className="h-4 w-4" />
Filters
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setShowSidebar(!showSidebar)}
className="shadow-lg md:hidden"
>
<Users className="h-4 w-4" />
{filteredProfiles.length} Cruisers
</Button>
<Badge variant="online" className="shadow-lg self-center">
{filteredProfiles.filter((p) => p.status !== "offline").length} active now
</Badge>
<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>
{/* Filter panel */}
{showFilters && (
<div className="absolute top-14 left-2 z-[1000] w-72 rounded-xl border border-border bg-horny-surface/95 backdrop-blur-md p-4 shadow-2xl space-y-4 max-h-[70vh] overflow-y-auto">
<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">Map Filters</span>
<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">Age: {filters.ageMin}{filters.ageMax}</Label>
<Label className="text-xs text-white/70">
Age: {filters.ageMin}{filters.ageMax}
</Label>
<Slider
min={18}
max={80}
@@ -208,9 +223,8 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
className="mt-2"
/>
</div>
<div>
<Label className="text-xs mb-2 block">Position</Label>
<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
@@ -224,11 +238,10 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
))}
</div>
</div>
<div>
<Label className="text-xs mb-2 block">Into</Label>
<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.map((k) => (
{KINK_OPTIONS.slice(0, 12).map((k) => (
<Badge
key={k}
variant={filters.kinks.includes(k) ? "default" : "outline"}
@@ -240,9 +253,8 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
))}
</div>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">On PrEP only</Label>
<Label className="text-xs text-white/70">On PrEP only</Label>
<Switch
checked={filters.onPrep === true}
onCheckedChange={(v) =>
@@ -250,9 +262,8 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
}
/>
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">Online only</Label>
<Label className="text-xs text-white/70">Online only</Label>
<Switch
checked={filters.onlineOnly}
onCheckedChange={(v) =>
@@ -263,105 +274,69 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
</div>
)}
{/* Sidebar — active cruisers */}
{showSidebar && (
<div className="absolute top-14 right-2 z-[1000] w-64 hidden md:block max-h-[calc(100%-5rem)] overflow-y-auto space-y-2 rounded-xl border border-border bg-horny-surface/95 backdrop-blur-md p-3 shadow-2xl">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Active Cruisers
</p>
{filteredProfiles
.filter((p) => p.status !== "offline")
.map((p) => (
<CruiserCard
key={p.id}
profile={p}
onChat={handleChat}
onView={setSelectedProfile}
vanillaMode={vanillaMode}
/>
))}
<PinkPulsePartner variant="card" className="mt-2" />
{/* Guest banner mobile */}
{!capabilities.canSendMessages && (
<div className="sm:hidden absolute top-12 left-2 right-2 z-[999]">
<GuestUpgradeBanner reason="general" compact />
</div>
)}
{/* Mobile — community partner strip above bottom nav */}
<div className="md:hidden absolute bottom-0 left-0 right-0 z-[999] pb-16">
<PinkPulsePartner variant="strip" />
</div>
{/* Map */}
{leafletReady && iconFactory && (
<MapContainer
center={[DEFAULT_LAT, DEFAULT_LNG]}
zoom={14}
className="h-full w-full"
zoom={15}
className="h-full w-full sniffies-map"
zoomControl={false}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://carto.com/">CARTO</a>'
url={DARK_TILES}
/>
<MarkerClusterGroup chunkedLoading>
{filteredProfiles.map((p) =>
p.lat && p.lng ? (
<Marker
key={p.id}
position={[p.lat, p.lng]}
icon={iconFactory.createUser(p.position, p.status)}
eventHandlers={{
click: () => setSelectedProfile(p),
}}
>
<Popup>
<div className="text-sm">
<strong>
{p.is_anonymous ? "Anonymous" : p.display_name || "Cruiser"}
</strong>
{p.position && (
<Badge variant={p.position} className="ml-2 text-[10px] uppercase">
{p.position}
</Badge>
)}
<p className="text-xs mt-1">{p.bio}</p>
<Button
variant="horny"
size="sm"
className="mt-2 w-full"
onClick={() => handleChat(p)}
>
Chat
</Button>
</div>
</Popup>
</Marker>
) : null
)}
</MarkerClusterGroup>
<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)}
>
<Popup>
<div className="text-sm">
<strong>{s.name}</strong>
<Badge variant="outline" className="ml-2 text-[10px]">
{s.category}
</Badge>
<p className="text-xs mt-1">{s.description}</p>
<p className="text-xs text-muted-foreground mt-1">
{s.active_count} active · {s.rating}
</p>
</div>
</Popup>
</Marker>
icon={iconFactory.createSpot(s.category, s.active_count)}
eventHandlers={{
click: () => {
/* spot detail future */
},
}}
/>
))}
</MapContainer>
)}
<ProfilePopup
{/* 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)}