feat: Sniffies-style map+profiles, guest age-verify without ID, limited access
This commit is contained in:
@@ -39,9 +39,133 @@
|
|||||||
|
|
||||||
/* Leaflet dark theme overrides */
|
/* Leaflet dark theme overrides */
|
||||||
.leaflet-container {
|
.leaflet-container {
|
||||||
background: #0d0a0f !important;
|
background: #0a1628 !important;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sniffies-map .leaflet-tile-pane {
|
||||||
|
filter: saturate(0.85) brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sniffies-style photo markers */
|
||||||
|
.sniffies-marker-wrap,
|
||||||
|
.sniffies-you-wrap,
|
||||||
|
.sniffies-spot-wrap {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-marker {
|
||||||
|
position: relative;
|
||||||
|
width: 52px;
|
||||||
|
height: 58px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-avatar-ring {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #3b82f6;
|
||||||
|
padding: 2px;
|
||||||
|
background: #0a1628;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-avatar-ring.sniffies-online {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-dist-badge {
|
||||||
|
margin-top: 2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f97316;
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-you {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-you-dot {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ef4444;
|
||||||
|
border: 3px solid white;
|
||||||
|
box-shadow: 0 2px 12px rgba(239, 68, 68, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-you-dot::after {
|
||||||
|
content: "";
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath 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'/%3E%3C/svg%3E") center/contain no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-you-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #3b82f6;
|
||||||
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-spot {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(10, 22, 40, 0.9);
|
||||||
|
border: 2px solid #3b82f6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-spot-count {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -4px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 3px;
|
||||||
|
}
|
||||||
.leaflet-popup-content-wrapper {
|
.leaflet-popup-content-wrapper {
|
||||||
background: hsl(280 25% 10%);
|
background: hsl(280 25% 10%);
|
||||||
color: hsl(0 0% 98%);
|
color: hsl(0 0% 98%);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function HomePage() {
|
|||||||
const vanillaMode = useVanillaMode();
|
const vanillaMode = useVanillaMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100dvh-7rem)] md:h-[calc(100dvh-3.5rem)]">
|
<div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] -mx-0 flex-1">
|
||||||
<CruisingMap
|
<CruisingMap
|
||||||
vanillaMode={vanillaMode}
|
vanillaMode={vanillaMode}
|
||||||
onActiveCountChange={setActiveCount}
|
onActiveCountChange={setActiveCount}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { PhotoGalleryManager } from "@/components/PhotoGalleryManager";
|
|||||||
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
||||||
import { IdVerificationModal } from "@/components/IdVerificationModal";
|
import { IdVerificationModal } from "@/components/IdVerificationModal";
|
||||||
import { PinkPulsePartner } from "@/components/PinkPulsePartner";
|
import { PinkPulsePartner } from "@/components/PinkPulsePartner";
|
||||||
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
|
import { useAccess } from "@/components/Providers";
|
||||||
import {
|
import {
|
||||||
US_STATES,
|
US_STATES,
|
||||||
getUserState,
|
getUserState,
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, profile, loading, updateProfile, signOut } = useAuth();
|
const { user, profile, loading, updateProfile, signOut } = useAuth();
|
||||||
|
const { capabilities } = useAccess();
|
||||||
const [authOpen, setAuthOpen] = useState(false);
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
const [idModalOpen, setIdModalOpen] = useState(false);
|
const [idModalOpen, setIdModalOpen] = useState(false);
|
||||||
const [isAnonymous, setIsAnonymous] = useState(true);
|
const [isAnonymous, setIsAnonymous] = useState(true);
|
||||||
@@ -152,7 +155,11 @@ export default function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{idRequired && (
|
{!user && (
|
||||||
|
<GuestUpgradeBanner reason="general" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{idRequired && user && (
|
||||||
<Card className="border-amber-500/30 bg-amber-500/5">
|
<Card className="border-amber-500/30 bg-amber-500/5">
|
||||||
<CardContent className="pt-4 pb-4">
|
<CardContent className="pt-4 pb-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
|
|||||||
@@ -144,11 +144,20 @@ export function AgeGate({ onVerified }: AgeGateProps) {
|
|||||||
{idRequired && (
|
{idRequired && (
|
||||||
<p className="text-xs text-amber-400 mt-1.5">
|
<p className="text-xs text-amber-400 mt-1.5">
|
||||||
{US_STATES.find((s) => s.code === state)?.name} requires ID verification
|
{US_STATES.find((s) => s.code === state)?.name} requires ID verification
|
||||||
to view nude photos. You can verify after entering.
|
for nude photos — not required to browse as a guest.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-blue-500/25 bg-blue-500/10 p-3">
|
||||||
|
<p className="text-xs text-blue-100/90 leading-relaxed">
|
||||||
|
<strong className="text-blue-200">No account needed.</strong> Verify your age
|
||||||
|
with date of birth only — browse the map, spots, and profiles free. Explicit
|
||||||
|
photos and chat require a free signup. Government ID is only needed for nude
|
||||||
|
content in certain states.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
} from "@/lib/canned-messages-store";
|
} from "@/lib/canned-messages-store";
|
||||||
import { MAX_CANNED_MESSAGES, MAX_CANNED_MESSAGE_LENGTH } from "@/lib/limits";
|
import { MAX_CANNED_MESSAGES, MAX_CANNED_MESSAGE_LENGTH } from "@/lib/limits";
|
||||||
import { useAuth } from "@/lib/auth/context";
|
import { useAuth } from "@/lib/auth/context";
|
||||||
|
import { useAccess } from "@/components/Providers";
|
||||||
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
|
|
||||||
interface CannedMessagesPanelProps {
|
interface CannedMessagesPanelProps {
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
@@ -23,6 +25,7 @@ interface CannedMessagesPanelProps {
|
|||||||
|
|
||||||
export function CannedMessagesPanel({ compact, onSelect }: CannedMessagesPanelProps) {
|
export function CannedMessagesPanel({ compact, onSelect }: CannedMessagesPanelProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { capabilities } = useAccess();
|
||||||
const [messages, setMessages] = useState<CannedMessage[]>([]);
|
const [messages, setMessages] = useState<CannedMessage[]>([]);
|
||||||
const [editing, setEditing] = useState<string | null>(null);
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
const [editTitle, setEditTitle] = useState("");
|
const [editTitle, setEditTitle] = useState("");
|
||||||
@@ -86,6 +89,10 @@ export function CannedMessagesPanel({ compact, onSelect }: CannedMessagesPanelPr
|
|||||||
await refresh();
|
await refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!capabilities.canUseQuickReplies && !compact) {
|
||||||
|
return <GuestUpgradeBanner reason="chat" />;
|
||||||
|
}
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { Send, Image as ImageIcon, Users, Lock, Zap, ChevronDown, ChevronUp } fr
|
|||||||
import { timeAgo } from "@/lib/utils";
|
import { timeAgo } from "@/lib/utils";
|
||||||
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
||||||
import { PinkPulsePartner } from "@/components/PinkPulsePartner";
|
import { PinkPulsePartner } from "@/components/PinkPulsePartner";
|
||||||
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
|
import { useAccess } from "@/components/Providers";
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,6 +71,7 @@ interface ChatPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
||||||
|
const { capabilities } = useAccess();
|
||||||
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
|
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
|
||||||
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
@@ -81,6 +84,7 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
|||||||
}, [cityMessages, dmMessages, activeTab]);
|
}, [cityMessages, dmMessages, activeTab]);
|
||||||
|
|
||||||
const sendMessage = (text?: string) => {
|
const sendMessage = (text?: string) => {
|
||||||
|
if (!capabilities.canSendMessages) return;
|
||||||
const content = (text ?? input).trim();
|
const content = (text ?? input).trim();
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
@@ -158,7 +162,12 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
|||||||
|
|
||||||
{showComposer && (
|
{showComposer && (
|
||||||
<div className="border-t border-border">
|
<div className="border-t border-border">
|
||||||
{activeTab === "dm" && (
|
{!capabilities.canSendMessages && (
|
||||||
|
<div className="px-3 sm:px-4 pt-3">
|
||||||
|
<GuestUpgradeBanner reason="chat" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === "dm" && capabilities.canSendMessages && (
|
||||||
<div className="px-3 sm:px-4 pt-2">
|
<div className="px-3 sm:px-4 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -181,21 +190,32 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-3 sm:p-4 flex gap-2 safe-bottom">
|
<div className="p-3 sm:p-4 flex gap-2 safe-bottom">
|
||||||
<Button variant="outline" size="icon" className="shrink-0 h-11 w-11 touch-manipulation">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 h-11 w-11 touch-manipulation"
|
||||||
|
disabled={!capabilities.canSendMessages}
|
||||||
|
>
|
||||||
<ImageIcon className="h-4 w-4" />
|
<ImageIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Type a message..."
|
placeholder={
|
||||||
|
capabilities.canSendMessages
|
||||||
|
? "Type a message..."
|
||||||
|
: "Sign up free to message..."
|
||||||
|
}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
|
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
|
||||||
className="flex-1 h-11 text-base"
|
className="flex-1 h-11 text-base"
|
||||||
|
disabled={!capabilities.canSendMessages}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="horny"
|
variant="horny"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="shrink-0 h-11 w-11 touch-manipulation"
|
className="shrink-0 h-11 w-11 touch-manipulation"
|
||||||
onClick={() => sendMessage()}
|
onClick={() => sendMessage()}
|
||||||
|
disabled={!capabilities.canSendMessages}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
414
src/components/CruiserProfileView.tsx
Normal file
414
src/components/CruiserProfileView.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ShieldCheck,
|
||||||
|
Share2,
|
||||||
|
Video,
|
||||||
|
Ruler,
|
||||||
|
Quote,
|
||||||
|
MapPin,
|
||||||
|
MessageCircle,
|
||||||
|
MoreHorizontal,
|
||||||
|
Lock,
|
||||||
|
Camera,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
|
import { useAccess } from "@/components/Providers";
|
||||||
|
import type { Profile } from "@/types";
|
||||||
|
import { formatStatsLine, formatDistance } from "@/lib/profile-stats";
|
||||||
|
import { timeAgo } from "@/lib/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface CruiserProfileViewProps {
|
||||||
|
profile: Profile | null;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onChat: (profile: Profile) => void;
|
||||||
|
onVerifyClick?: () => void;
|
||||||
|
vanillaMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = "about" | "health" | "media";
|
||||||
|
|
||||||
|
function SectionRow({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3 py-3 border-b border-white/10">
|
||||||
|
<span className="text-sm text-white/70 shrink-0">{label}</span>
|
||||||
|
<div className="text-sm text-right text-white flex flex-wrap gap-1.5 justify-end">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pill({
|
||||||
|
children,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
active?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block px-3 py-1 rounded-full text-xs border",
|
||||||
|
active
|
||||||
|
? "bg-white/15 border-white/30 text-white"
|
||||||
|
: "border-white/25 text-white/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CruiserProfileView({
|
||||||
|
profile,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onChat,
|
||||||
|
onVerifyClick,
|
||||||
|
vanillaMode,
|
||||||
|
}: CruiserProfileViewProps) {
|
||||||
|
const { capabilities, blurExplicit } = useAccess();
|
||||||
|
const [tab, setTab] = useState<Tab>("about");
|
||||||
|
const [activePhoto, setActivePhoto] = useState(0);
|
||||||
|
|
||||||
|
if (!profile || !open) return null;
|
||||||
|
|
||||||
|
const name = profile.is_verified
|
||||||
|
? "Verified Cruiser"
|
||||||
|
: profile.is_anonymous
|
||||||
|
? "Anonymous"
|
||||||
|
: profile.display_name || profile.username || "Cruiser";
|
||||||
|
|
||||||
|
const stats = formatStatsLine(profile);
|
||||||
|
const photos = profile.gallery?.length
|
||||||
|
? profile.gallery
|
||||||
|
: profile.avatar_url
|
||||||
|
? [{ url: profile.avatar_url, is_explicit: false }]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const shouldBlurPhoto = (explicit: boolean) =>
|
||||||
|
vanillaMode || blurExplicit || (explicit && !capabilities.canViewExplicit);
|
||||||
|
|
||||||
|
const handleChat = () => {
|
||||||
|
if (!capabilities.canSendMessages) return;
|
||||||
|
onChat(profile);
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbs = photos.slice(1, 3);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[2000] bg-black flex flex-col md:items-center md:justify-center md:p-4">
|
||||||
|
<div className="w-full h-full md:max-w-lg md:max-h-[92vh] md:rounded-2xl md:overflow-hidden flex flex-col bg-[#0a1628]">
|
||||||
|
{/* Header — Sniffies style */}
|
||||||
|
<div className="shrink-0 bg-black px-3 py-2 safe-top">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 text-blue-400 touch-manipulation"
|
||||||
|
aria-label="Back"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{profile.is_verified && (
|
||||||
|
<ShieldCheck className="h-4 w-4 text-blue-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="font-bold text-white truncate">{name}</span>
|
||||||
|
</div>
|
||||||
|
{stats && (
|
||||||
|
<p className="text-xs text-white/80 truncate">{stats}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="p-2 text-white/70" aria-label="Share">
|
||||||
|
<Share2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="p-2 text-white/70" aria-label="Video">
|
||||||
|
<Video className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
{/* Hero photo + thumbnails */}
|
||||||
|
<div className="relative aspect-[4/5] max-h-[50vh] bg-black">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative w-full h-full",
|
||||||
|
shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && "id-blur"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={photos[activePhoto]?.url || "https://placehold.co/400x500/0a1628/3b82f6?text=?"}
|
||||||
|
alt={name}
|
||||||
|
fill
|
||||||
|
className="object-cover explicit-content"
|
||||||
|
priority
|
||||||
|
unoptimized
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{shouldBlurPhoto(photos[activePhoto]?.is_explicit ?? false) && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/50 gap-2 px-6 text-center">
|
||||||
|
<Lock className="h-8 w-8 text-white" />
|
||||||
|
<p className="text-sm text-white font-medium">
|
||||||
|
{!capabilities.canSendMessages
|
||||||
|
? "Sign up free to view explicit photos"
|
||||||
|
: "Verify ID to view nude photos in your state"}
|
||||||
|
</p>
|
||||||
|
{!capabilities.canSendMessages ? (
|
||||||
|
<GuestUpgradeBanner reason="explicit" compact />
|
||||||
|
) : (
|
||||||
|
onVerifyClick && (
|
||||||
|
<Button variant="horny" size="sm" onClick={onVerifyClick}>
|
||||||
|
Verify ID
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{thumbs.length > 0 && (
|
||||||
|
<div className="absolute top-3 right-3 flex flex-col gap-2">
|
||||||
|
{thumbs.map((ph, i) => (
|
||||||
|
<button
|
||||||
|
key={ph.url}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActivePhoto(i + 1)}
|
||||||
|
className={cn(
|
||||||
|
"relative w-14 h-14 rounded-full overflow-hidden border-2 border-blue-400",
|
||||||
|
shouldBlurPhoto(ph.is_explicit) && "id-blur"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image src={ph.url} alt="" fill className="object-cover" unoptimized />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats + headline */}
|
||||||
|
<div className="px-4 py-3 space-y-2 bg-[#0a1628]">
|
||||||
|
{stats && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-white/90">
|
||||||
|
<Ruler className="h-4 w-4 text-white/50" />
|
||||||
|
{stats}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{profile.headline && (
|
||||||
|
<p className="flex items-start gap-2 text-sm font-medium text-white">
|
||||||
|
<Quote className="h-4 w-4 text-white/50 shrink-0 mt-0.5" />
|
||||||
|
{profile.headline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{profile.bio && (
|
||||||
|
<p className="text-sm text-white/70">{profile.bio}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!capabilities.canSendMessages && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<GuestUpgradeBanner reason="general" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-white/10 px-2">
|
||||||
|
{(["about", "health", "media"] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 py-2.5 text-xs font-semibold uppercase tracking-wide touch-manipulation",
|
||||||
|
tab === t ? "text-blue-400 border-b-2 border-blue-400" : "text-white/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-24">
|
||||||
|
{tab === "about" && (
|
||||||
|
<div>
|
||||||
|
{profile.hosting && (
|
||||||
|
<SectionRow label="Location">
|
||||||
|
<Pill active>{profile.hosting}</Pill>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.into_public && profile.into_public.length > 0 && (
|
||||||
|
<SectionRow label="Into Public">
|
||||||
|
{profile.into_public.map((x) => (
|
||||||
|
<Pill key={x} active={x === profile.into_public?.[0]}>
|
||||||
|
{x}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.fetishes && profile.fetishes.length > 0 && (
|
||||||
|
<SectionRow label="Fetishes">
|
||||||
|
<span className="text-white/90">{profile.fetishes.join(" ")}</span>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.kinks.length > 0 && (
|
||||||
|
<SectionRow label="Kinks">
|
||||||
|
{profile.kinks.map((k) => (
|
||||||
|
<Pill key={k} active>
|
||||||
|
{k}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.practices && profile.practices.length > 0 && (
|
||||||
|
<SectionRow label="Practices">
|
||||||
|
{profile.practices.map((p) => (
|
||||||
|
<Pill key={p} active>
|
||||||
|
{p}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.interaction && profile.interaction.length > 0 && (
|
||||||
|
<SectionRow label="Interaction">
|
||||||
|
{profile.interaction.map((x) => (
|
||||||
|
<span key={x} className="text-white/90">
|
||||||
|
{x}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "health" && (
|
||||||
|
<div>
|
||||||
|
{profile.hiv_status && (
|
||||||
|
<SectionRow label="HIV Status">
|
||||||
|
<span>{profile.hiv_status}</span>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.hiv_tested && (
|
||||||
|
<SectionRow label="HIV Tested">
|
||||||
|
<span>{profile.hiv_tested}</span>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.sti_tested && (
|
||||||
|
<SectionRow label="STI Tested">
|
||||||
|
<span>{profile.sti_tested}</span>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.sti_status && !profile.hiv_status && (
|
||||||
|
<SectionRow label="STI Status">
|
||||||
|
<span>{profile.sti_status}</span>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.safeguards && profile.safeguards.length > 0 && (
|
||||||
|
<SectionRow label="Safeguards">
|
||||||
|
{profile.safeguards.map((s) => (
|
||||||
|
<Pill key={s}>{s}</Pill>
|
||||||
|
))}
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.comfort_levels && profile.comfort_levels.length > 0 && (
|
||||||
|
<SectionRow label="My Comfort Levels">
|
||||||
|
{profile.comfort_levels.map((c) => (
|
||||||
|
<span key={c} className="text-white/90 block">
|
||||||
|
{c}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.carries && profile.carries.length > 0 && (
|
||||||
|
<SectionRow label="I carry...">
|
||||||
|
<span>{profile.carries.join(", ")}</span>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
{profile.on_prep && !profile.hiv_status && (
|
||||||
|
<SectionRow label="PrEP">
|
||||||
|
<Pill active>On PrEP</Pill>
|
||||||
|
</SectionRow>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "media" && (
|
||||||
|
<div className="pt-4">
|
||||||
|
{!capabilities.canViewExplicit && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<GuestUpgradeBanner
|
||||||
|
reason={capabilities.canSendMessages ? "explicit" : "media"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{photos.map((ph, i) => (
|
||||||
|
<button
|
||||||
|
key={`${ph.url}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActivePhoto(i)}
|
||||||
|
className={cn(
|
||||||
|
"relative aspect-square rounded overflow-hidden",
|
||||||
|
shouldBlurPhoto(ph.is_explicit) && "id-blur"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image src={ph.url} alt="" fill className="object-cover" unoptimized />
|
||||||
|
{shouldBlurPhoto(ph.is_explicit) && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||||
|
<Lock className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{capabilities.canUploadPhotos && (
|
||||||
|
<div className="aspect-square rounded border-2 border-dashed border-white/20 flex items-center justify-center">
|
||||||
|
<Camera className="h-8 w-8 text-white/30" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="shrink-0 border-t border-white/10 bg-black px-4 py-3 flex items-center justify-between safe-bottom">
|
||||||
|
<button type="button" className="p-2 text-white/60" aria-label="More">
|
||||||
|
<MoreHorizontal className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-white/70">
|
||||||
|
<span>{timeAgo(profile.last_active)}</span>
|
||||||
|
{profile.distance_miles != null && (
|
||||||
|
<span className="flex items-center gap-1 text-blue-400">
|
||||||
|
<MapPin className="h-3 w-3" />
|
||||||
|
{formatDistance(profile.distance_miles)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="horny"
|
||||||
|
size="icon"
|
||||||
|
className="rounded-full h-11 w-11 shrink-0"
|
||||||
|
onClick={handleChat}
|
||||||
|
disabled={!capabilities.canSendMessages}
|
||||||
|
title={capabilities.canSendMessages ? "Chat" : "Sign up to chat"}
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/GuestUpgradeBanner.tsx
Normal file
92
src/components/GuestUpgradeBanner.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Lock, UserPlus, X, ShieldCheck } from "lucide-react";
|
||||||
|
import { AuthModal } from "@/components/AuthModal";
|
||||||
|
|
||||||
|
interface GuestUpgradeBannerProps {
|
||||||
|
reason?: "explicit" | "chat" | "media" | "general";
|
||||||
|
compact?: boolean;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES = {
|
||||||
|
explicit: {
|
||||||
|
title: "Explicit photos are for members",
|
||||||
|
body: "You verified your age — great. Create a free account to unlock x-rated photos, or complete ID verification in restricted states.",
|
||||||
|
},
|
||||||
|
chat: {
|
||||||
|
title: "Sign up free to chat",
|
||||||
|
body: "Guests can browse the map and profiles. Join free to message cruisers, save quick replies, and upload photos.",
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
title: "Media gallery locked",
|
||||||
|
body: "Register free to view full photo grids and share your own pics (up to 15).",
|
||||||
|
},
|
||||||
|
general: {
|
||||||
|
title: "Limited guest access",
|
||||||
|
body: "Age verified — no ID required to browse. Sign up free for chat, photos, and explicit content (ID may be required in your state).",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GuestUpgradeBanner({
|
||||||
|
reason = "general",
|
||||||
|
compact,
|
||||||
|
onDismiss,
|
||||||
|
}: GuestUpgradeBannerProps) {
|
||||||
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
|
const msg = MESSAGES[reason];
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAuthOpen(true)}
|
||||||
|
className="flex items-center gap-2 w-full rounded-lg border border-horny-pink/30 bg-horny-pink/10 px-3 py-2 text-left touch-manipulation"
|
||||||
|
>
|
||||||
|
<Lock className="h-4 w-4 text-horny-pink shrink-0" />
|
||||||
|
<span className="text-xs font-medium">{msg.title}</span>
|
||||||
|
</button>
|
||||||
|
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl border border-amber-500/25 bg-[#0a1628]/90 p-4 relative">
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
className="absolute top-2 right-2 p-1 text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-500/20">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 pr-6">
|
||||||
|
<p className="text-sm font-semibold text-blue-100">{msg.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">{msg.body}</p>
|
||||||
|
<Button
|
||||||
|
variant="horny"
|
||||||
|
size="sm"
|
||||||
|
className="mt-3 gap-1.5"
|
||||||
|
onClick={() => setAuthOpen(true)}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3.5 w-3.5" />
|
||||||
|
Join Free — No Credit Card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,18 +7,16 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { CruiserCard } from "@/components/CruiserCard";
|
import { CruiserProfileView } from "@/components/CruiserProfileView";
|
||||||
import { ProfilePopup } from "@/components/ProfilePopup";
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
import { Filter, Users, X } from "lucide-react";
|
import { Filter, X, Locate } from "lucide-react";
|
||||||
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
|
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 { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
||||||
import { useOpenIdVerification } from "@/components/Providers";
|
import { useOpenIdVerification, useAccess } from "@/components/Providers";
|
||||||
import { PinkPulsePartner } from "@/components/PinkPulsePartner";
|
import { distanceMiles } from "@/lib/profile-stats";
|
||||||
|
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
import "leaflet.markercluster/dist/MarkerCluster.css";
|
|
||||||
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
|
|
||||||
|
|
||||||
const MapContainer = dynamic(
|
const MapContainer = dynamic(
|
||||||
() => import("react-leaflet").then((m) => m.MapContainer),
|
() => import("react-leaflet").then((m) => m.MapContainer),
|
||||||
@@ -32,17 +30,11 @@ const Marker = dynamic(
|
|||||||
() => import("react-leaflet").then((m) => m.Marker),
|
() => import("react-leaflet").then((m) => m.Marker),
|
||||||
{ ssr: false }
|
{ 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_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
|
||||||
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
|
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 {
|
interface MapProps {
|
||||||
vanillaMode: boolean;
|
vanillaMode: boolean;
|
||||||
@@ -51,15 +43,16 @@ interface MapProps {
|
|||||||
|
|
||||||
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
||||||
const openIdVerification = useOpenIdVerification();
|
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 [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
|
||||||
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [showSidebar, setShowSidebar] = useState(true);
|
|
||||||
const [leafletReady, setLeafletReady] = useState(false);
|
const [leafletReady, setLeafletReady] = useState(false);
|
||||||
const [iconFactory, setIconFactory] = useState<{
|
const [iconFactory, setIconFactory] = useState<{
|
||||||
createUser: (position: string | null, status: string) => import("leaflet").DivIcon;
|
createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon;
|
||||||
createSpot: (category: string) => import("leaflet").DivIcon;
|
createYou: () => import("leaflet").DivIcon;
|
||||||
|
createSpot: (category: string, count: number) => import("leaflet").DivIcon;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<MapFilters>({
|
const [filters, setFilters] = useState<MapFilters>({
|
||||||
@@ -74,46 +67,67 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import("leaflet").then((L) => {
|
import("leaflet").then((L) => {
|
||||||
const createUserIcon = (position: string | null, status: string) => {
|
const createPhoto = (p: Profile, blur: boolean) => {
|
||||||
const color = position
|
const url =
|
||||||
? POSITION_COLORS[position as Position] || "#ff2d6b"
|
p.avatar_url ||
|
||||||
: "#ff2d6b";
|
"https://placehold.co/96x96/0a1628/3b82f6?text=?";
|
||||||
const pulse = status !== "offline" ? "animation:pulse 2s infinite;" : "";
|
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({
|
return L.divIcon({
|
||||||
className: "custom-marker",
|
className: "sniffies-marker-wrap",
|
||||||
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}">
|
html: `<div class="sniffies-marker">
|
||||||
<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>
|
<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>`,
|
</div>`,
|
||||||
iconSize: [36, 36],
|
iconSize: [36, 36],
|
||||||
iconAnchor: [18, 18],
|
iconAnchor: [18, 18],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createSpotIcon = (category: string) => {
|
setIconFactory({ createPhoto, createYou, createSpot });
|
||||||
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,
|
|
||||||
});
|
|
||||||
setLeafletReady(true);
|
setLeafletReady(true);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -137,8 +151,9 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
}, [filteredProfiles, onActiveCountChange]);
|
}, [filteredProfiles, onActiveCountChange]);
|
||||||
|
|
||||||
const handleChat = useCallback((profile: Profile) => {
|
const handleChat = useCallback((profile: Profile) => {
|
||||||
|
if (!capabilities.canSendMessages) return;
|
||||||
window.location.href = `/chat?user=${profile.id}`;
|
window.location.href = `/chat?user=${profile.id}`;
|
||||||
}, []);
|
}, [capabilities.canSendMessages]);
|
||||||
|
|
||||||
const togglePosition = (pos: Position) => {
|
const togglePosition = (pos: Position) => {
|
||||||
setFilters((f) => ({
|
setFilters((f) => ({
|
||||||
@@ -159,44 +174,44 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full bg-[#0a1628]">
|
||||||
{/* Filter bar */}
|
{/* Minimal top controls — Sniffies style */}
|
||||||
<div className="absolute top-2 left-2 right-2 z-[1000] flex gap-2 flex-wrap">
|
<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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
className="shadow-lg"
|
className="shadow-lg bg-[#0a1628]/90 border-white/10 text-white h-9"
|
||||||
>
|
>
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
Filters
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Badge
|
||||||
variant="secondary"
|
variant="online"
|
||||||
size="sm"
|
className="shadow-lg self-center bg-blue-500/20 text-blue-300 border-blue-500/30"
|
||||||
onClick={() => setShowSidebar(!showSidebar)}
|
|
||||||
className="shadow-lg md:hidden"
|
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4" />
|
{filteredProfiles.filter((p) => p.status !== "offline").length} cruising
|
||||||
{filteredProfiles.length} Cruisers
|
|
||||||
</Button>
|
|
||||||
<Badge variant="online" className="shadow-lg self-center">
|
|
||||||
{filteredProfiles.filter((p) => p.status !== "offline").length} active now
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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 && (
|
{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">
|
<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)}>
|
<Button variant="ghost" size="icon" onClick={() => setShowFilters(false)}>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Slider
|
||||||
min={18}
|
min={18}
|
||||||
max={80}
|
max={80}
|
||||||
@@ -208,9 +223,8 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
className="mt-2"
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div className="flex flex-wrap gap-1">
|
||||||
{(["top", "bottom", "vers", "side"] as Position[]).map((pos) => (
|
{(["top", "bottom", "vers", "side"] as Position[]).map((pos) => (
|
||||||
<Badge
|
<Badge
|
||||||
@@ -224,11 +238,10 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<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
|
<Badge
|
||||||
key={k}
|
key={k}
|
||||||
variant={filters.kinks.includes(k) ? "default" : "outline"}
|
variant={filters.kinks.includes(k) ? "default" : "outline"}
|
||||||
@@ -240,9 +253,8 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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
|
<Switch
|
||||||
checked={filters.onPrep === true}
|
checked={filters.onPrep === true}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={(v) =>
|
||||||
@@ -250,9 +262,8 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<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
|
<Switch
|
||||||
checked={filters.onlineOnly}
|
checked={filters.onlineOnly}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={(v) =>
|
||||||
@@ -263,105 +274,69 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar — active cruisers */}
|
{/* Guest banner mobile */}
|
||||||
{showSidebar && (
|
{!capabilities.canSendMessages && (
|
||||||
<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">
|
<div className="sm:hidden absolute top-12 left-2 right-2 z-[999]">
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
<GuestUpgradeBanner reason="general" compact />
|
||||||
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" />
|
|
||||||
</div>
|
</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 && (
|
{leafletReady && iconFactory && (
|
||||||
<MapContainer
|
<MapContainer
|
||||||
center={[DEFAULT_LAT, DEFAULT_LNG]}
|
center={[DEFAULT_LAT, DEFAULT_LNG]}
|
||||||
zoom={14}
|
zoom={15}
|
||||||
className="h-full w-full"
|
className="h-full w-full sniffies-map"
|
||||||
zoomControl={false}
|
zoomControl={false}
|
||||||
>
|
>
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
attribution='© <a href="https://carto.com/">CARTO</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url={DARK_TILES}
|
||||||
/>
|
/>
|
||||||
<MarkerClusterGroup chunkedLoading>
|
|
||||||
|
<Marker
|
||||||
|
position={[DEFAULT_LAT, DEFAULT_LNG]}
|
||||||
|
icon={iconFactory.createYou()}
|
||||||
|
zIndexOffset={1000}
|
||||||
|
/>
|
||||||
|
|
||||||
{filteredProfiles.map((p) =>
|
{filteredProfiles.map((p) =>
|
||||||
p.lat && p.lng ? (
|
p.lat && p.lng ? (
|
||||||
<Marker
|
<Marker
|
||||||
key={p.id}
|
key={p.id}
|
||||||
position={[p.lat, p.lng]}
|
position={[p.lat, p.lng]}
|
||||||
icon={iconFactory.createUser(p.position, p.status)}
|
icon={iconFactory.createPhoto(p, blurExplicit)}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => setSelectedProfile(p),
|
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
|
) : null
|
||||||
)}
|
)}
|
||||||
</MarkerClusterGroup>
|
|
||||||
|
|
||||||
{spots.map((s) => (
|
{spots.map((s) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={s.id}
|
key={s.id}
|
||||||
position={[s.lat, s.lng]}
|
position={[s.lat, s.lng]}
|
||||||
icon={iconFactory.createSpot(s.category)}
|
icon={iconFactory.createSpot(s.category, s.active_count)}
|
||||||
>
|
eventHandlers={{
|
||||||
<Popup>
|
click: () => {
|
||||||
<div className="text-sm">
|
/* spot detail future */
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
</MapContainer>
|
</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}
|
profile={selectedProfile}
|
||||||
open={!!selectedProfile}
|
open={!!selectedProfile}
|
||||||
onClose={() => setSelectedProfile(null)}
|
onClose={() => setSelectedProfile(null)}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ interface MobileHeaderProps {
|
|||||||
vanillaMode: boolean;
|
vanillaMode: boolean;
|
||||||
onVanillaToggle: (v: boolean) => void;
|
onVanillaToggle: (v: boolean) => void;
|
||||||
activeCount?: number;
|
activeCount?: number;
|
||||||
|
isGuest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileHeader({
|
export function MobileHeader({
|
||||||
vanillaMode,
|
vanillaMode,
|
||||||
onVanillaToggle,
|
onVanillaToggle,
|
||||||
activeCount = 0,
|
activeCount = 0,
|
||||||
|
isGuest,
|
||||||
}: MobileHeaderProps) {
|
}: MobileHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className="md:hidden fixed top-0 left-0 right-0 z-50 flex h-12 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-3 safe-top">
|
<header className="md:hidden fixed top-0 left-0 right-0 z-50 flex h-12 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-3 safe-top">
|
||||||
@@ -41,9 +43,15 @@ export function MobileHeader({
|
|||||||
>
|
>
|
||||||
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
{isGuest ? (
|
||||||
|
<Badge variant="outline" className="text-[8px] px-1.5 text-blue-400 border-blue-400/30">
|
||||||
|
Guest
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
<Badge variant="secondary" className="text-[8px] px-1.5">
|
<Badge variant="secondary" className="text-[8px] px-1.5">
|
||||||
FREE
|
FREE
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface NavProps {
|
|||||||
activeCount?: number;
|
activeCount?: number;
|
||||||
onIdVerifyClick?: () => void;
|
onIdVerifyClick?: () => void;
|
||||||
idBlur?: boolean;
|
idBlur?: boolean;
|
||||||
|
isGuest?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -35,6 +36,7 @@ export function Nav({
|
|||||||
activeCount = 0,
|
activeCount = 0,
|
||||||
onIdVerifyClick,
|
onIdVerifyClick,
|
||||||
idBlur,
|
idBlur,
|
||||||
|
isGuest,
|
||||||
}: NavProps) {
|
}: NavProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const itemCount = useCartStore((s) => s.itemCount());
|
const itemCount = useCartStore((s) => s.itemCount());
|
||||||
@@ -107,6 +109,11 @@ export function Nav({
|
|||||||
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
Vanilla
|
Vanilla
|
||||||
</Button>
|
</Button>
|
||||||
|
{isGuest && (
|
||||||
|
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-400/30">
|
||||||
|
Guest
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{user ? (
|
{user ? (
|
||||||
<Button variant="ghost" size="sm" onClick={() => signOut()} className="gap-2">
|
<Button variant="ghost" size="sm" onClick={() => signOut()} className="gap-2">
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ import {
|
|||||||
MAX_ALBUMS_PER_USER,
|
MAX_ALBUMS_PER_USER,
|
||||||
MAX_PHOTO_SIZE_MB,
|
MAX_PHOTO_SIZE_MB,
|
||||||
} from "@/lib/limits";
|
} from "@/lib/limits";
|
||||||
import { mustBlurExplicit } from "@/lib/id-verification";
|
|
||||||
import { useAuth } from "@/lib/auth/context";
|
import { useAuth } from "@/lib/auth/context";
|
||||||
|
import { useAccess } from "@/components/Providers";
|
||||||
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
|
|
||||||
interface PhotoGalleryManagerProps {
|
interface PhotoGalleryManagerProps {
|
||||||
onVerifyClick?: () => void;
|
onVerifyClick?: () => void;
|
||||||
@@ -32,6 +33,7 @@ interface PhotoGalleryManagerProps {
|
|||||||
|
|
||||||
export function PhotoGalleryManager({ onVerifyClick }: PhotoGalleryManagerProps) {
|
export function PhotoGalleryManager({ onVerifyClick }: PhotoGalleryManagerProps) {
|
||||||
const { user, profile } = useAuth();
|
const { user, profile } = useAuth();
|
||||||
|
const { capabilities, blurExplicit } = useAccess();
|
||||||
const [albums, setAlbums] = useState<GalleryAlbum[]>([]);
|
const [albums, setAlbums] = useState<GalleryAlbum[]>([]);
|
||||||
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
|
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
|
||||||
const [activeAlbum, setActiveAlbum] = useState<string>("main");
|
const [activeAlbum, setActiveAlbum] = useState<string>("main");
|
||||||
@@ -40,7 +42,9 @@ export function PhotoGalleryManager({ onVerifyClick }: PhotoGalleryManagerProps)
|
|||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const blurExplicit = mustBlurExplicit(profile?.id_verified);
|
if (!capabilities.canUploadPhotos) {
|
||||||
|
return <GuestUpgradeBanner reason="media" />;
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, createContext, useContext, useCallback } from "react";
|
import { useState, useEffect, createContext, useContext, useCallback, useMemo } from "react";
|
||||||
import { AgeGate } from "@/components/AgeGate";
|
import { AgeGate } from "@/components/AgeGate";
|
||||||
import { Nav } from "@/components/Nav";
|
import { Nav } from "@/components/Nav";
|
||||||
import { MobileHeader } from "@/components/MobileHeader";
|
import { MobileHeader } from "@/components/MobileHeader";
|
||||||
@@ -8,17 +8,30 @@ import { IdVerificationModal } from "@/components/IdVerificationModal";
|
|||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { AuthProvider, useAuth } from "@/lib/auth/context";
|
import { AuthProvider, useAuth } from "@/lib/auth/context";
|
||||||
import {
|
import {
|
||||||
mustBlurExplicit,
|
|
||||||
requiresIdVerification,
|
requiresIdVerification,
|
||||||
getUserState,
|
getUserState,
|
||||||
isIdVerified,
|
isIdVerified,
|
||||||
} from "@/lib/id-verification";
|
} from "@/lib/id-verification";
|
||||||
|
import {
|
||||||
|
getAccessCapabilities,
|
||||||
|
shouldBlurExplicit,
|
||||||
|
type AccessCapabilities,
|
||||||
|
} from "@/lib/access-tier";
|
||||||
|
|
||||||
const ActiveCountContext = createContext<(n: number) => void>(() => {});
|
const ActiveCountContext = createContext<(n: number) => void>(() => {});
|
||||||
const VanillaModeContext = createContext(false);
|
const VanillaModeContext = createContext(false);
|
||||||
const IdBlurContext = createContext(false);
|
|
||||||
const OpenIdVerificationContext = createContext<() => void>(() => {});
|
const OpenIdVerificationContext = createContext<() => void>(() => {});
|
||||||
|
|
||||||
|
interface AccessContextValue {
|
||||||
|
capabilities: AccessCapabilities;
|
||||||
|
blurExplicit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccessContext = createContext<AccessContextValue>({
|
||||||
|
capabilities: getAccessCapabilities(false),
|
||||||
|
blurExplicit: true,
|
||||||
|
});
|
||||||
|
|
||||||
export function useSetActiveCount() {
|
export function useSetActiveCount() {
|
||||||
return useContext(ActiveCountContext);
|
return useContext(ActiveCountContext);
|
||||||
}
|
}
|
||||||
@@ -27,39 +40,53 @@ export function useVanillaMode() {
|
|||||||
return useContext(VanillaModeContext);
|
return useContext(VanillaModeContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useIdBlur() {
|
|
||||||
return useContext(IdBlurContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOpenIdVerification() {
|
export function useOpenIdVerification() {
|
||||||
return useContext(OpenIdVerificationContext);
|
return useContext(OpenIdVerificationContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAccess() {
|
||||||
|
return useContext(AccessContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated use useAccess().blurExplicit */
|
||||||
|
export function useIdBlur() {
|
||||||
|
return useContext(AccessContext).blurExplicit;
|
||||||
|
}
|
||||||
|
|
||||||
interface ProvidersProps {
|
interface ProvidersProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function InnerProviders({ children }: ProvidersProps) {
|
function InnerProviders({ children }: ProvidersProps) {
|
||||||
const { profile } = useAuth();
|
const { user, profile } = useAuth();
|
||||||
const [vanillaMode, setVanillaMode] = useState(false);
|
const [vanillaMode, setVanillaMode] = useState(false);
|
||||||
const [activeCount, setActiveCount] = useState(0);
|
const [activeCount, setActiveCount] = useState(0);
|
||||||
const [idBlur, setIdBlur] = useState(false);
|
|
||||||
const [idModalOpen, setIdModalOpen] = useState(false);
|
const [idModalOpen, setIdModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const isLoggedIn = !!user;
|
||||||
|
const capabilities = useMemo(
|
||||||
|
() => getAccessCapabilities(isLoggedIn, profile?.id_verified),
|
||||||
|
[isLoggedIn, profile?.id_verified]
|
||||||
|
);
|
||||||
|
const blurExplicit = useMemo(
|
||||||
|
() => shouldBlurExplicit(isLoggedIn, profile?.id_verified) || vanillaMode,
|
||||||
|
[isLoggedIn, profile?.id_verified, vanillaMode]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
|
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
|
||||||
setIdBlur(mustBlurExplicit(profile?.id_verified));
|
|
||||||
|
|
||||||
const state = getUserState();
|
const state = getUserState();
|
||||||
if (
|
if (
|
||||||
|
isLoggedIn &&
|
||||||
requiresIdVerification(state) &&
|
requiresIdVerification(state) &&
|
||||||
!isIdVerified(profile?.id_verified) &&
|
!isIdVerified(profile?.id_verified) &&
|
||||||
localStorage.getItem("eg_id_verification_prompted") !== "true"
|
localStorage.getItem("eg_id_verification_prompted") !== "true"
|
||||||
) {
|
) {
|
||||||
const timer = setTimeout(() => setIdModalOpen(true), 1500);
|
const timer = setTimeout(() => setIdModalOpen(true), 2000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [profile?.id_verified]);
|
}, [isLoggedIn, profile?.id_verified]);
|
||||||
|
|
||||||
const handleVanillaToggle = (v: boolean) => {
|
const handleVanillaToggle = (v: boolean) => {
|
||||||
setVanillaMode(v);
|
setVanillaMode(v);
|
||||||
@@ -67,7 +94,6 @@ function InnerProviders({ children }: ProvidersProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleIdVerified = useCallback(() => {
|
const handleIdVerified = useCallback(() => {
|
||||||
setIdBlur(false);
|
|
||||||
localStorage.setItem("eg_id_verification_prompted", "true");
|
localStorage.setItem("eg_id_verification_prompted", "true");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -77,19 +103,21 @@ function InnerProviders({ children }: ProvidersProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VanillaModeContext.Provider value={vanillaMode}>
|
<VanillaModeContext.Provider value={vanillaMode}>
|
||||||
<IdBlurContext.Provider value={idBlur}>
|
<AccessContext.Provider value={{ capabilities, blurExplicit }}>
|
||||||
<OpenIdVerificationContext.Provider value={openIdVerification}>
|
<OpenIdVerificationContext.Provider value={openIdVerification}>
|
||||||
<Nav
|
<Nav
|
||||||
vanillaMode={vanillaMode}
|
vanillaMode={vanillaMode}
|
||||||
onVanillaToggle={handleVanillaToggle}
|
onVanillaToggle={handleVanillaToggle}
|
||||||
activeCount={activeCount}
|
activeCount={activeCount}
|
||||||
onIdVerifyClick={openIdVerification}
|
onIdVerifyClick={openIdVerification}
|
||||||
idBlur={idBlur}
|
idBlur={blurExplicit && isLoggedIn}
|
||||||
|
isGuest={!isLoggedIn}
|
||||||
/>
|
/>
|
||||||
<MobileHeader
|
<MobileHeader
|
||||||
vanillaMode={vanillaMode}
|
vanillaMode={vanillaMode}
|
||||||
onVanillaToggle={handleVanillaToggle}
|
onVanillaToggle={handleVanillaToggle}
|
||||||
activeCount={activeCount}
|
activeCount={activeCount}
|
||||||
|
isGuest={!isLoggedIn}
|
||||||
/>
|
/>
|
||||||
<main className="md:pt-14 pt-12 pb-16 md:pb-0 min-h-screen min-h-[100dvh] flex flex-col">
|
<main className="md:pt-14 pt-12 pb-16 md:pb-0 min-h-screen min-h-[100dvh] flex flex-col">
|
||||||
<ActiveCountContext.Provider value={setActiveCount}>
|
<ActiveCountContext.Provider value={setActiveCount}>
|
||||||
@@ -106,7 +134,7 @@ function InnerProviders({ children }: ProvidersProps) {
|
|||||||
onVerified={handleIdVerified}
|
onVerified={handleIdVerified}
|
||||||
/>
|
/>
|
||||||
</OpenIdVerificationContext.Provider>
|
</OpenIdVerificationContext.Provider>
|
||||||
</IdBlurContext.Provider>
|
</AccessContext.Provider>
|
||||||
</VanillaModeContext.Provider>
|
</VanillaModeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/lib/access-tier.ts
Normal file
72
src/lib/access-tier.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
requiresIdVerification,
|
||||||
|
getUserState,
|
||||||
|
isIdVerified,
|
||||||
|
} from "@/lib/id-verification";
|
||||||
|
|
||||||
|
export type AccessTier = "guest" | "member" | "verified";
|
||||||
|
|
||||||
|
export interface AccessCapabilities {
|
||||||
|
tier: AccessTier;
|
||||||
|
canViewExplicit: boolean;
|
||||||
|
canSendMessages: boolean;
|
||||||
|
canUploadPhotos: boolean;
|
||||||
|
canUseQuickReplies: boolean;
|
||||||
|
canPostSpots: boolean;
|
||||||
|
canShareMedia: boolean;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessTier(
|
||||||
|
isLoggedIn: boolean,
|
||||||
|
profileIdVerified?: boolean
|
||||||
|
): AccessTier {
|
||||||
|
if (!isLoggedIn) return "guest";
|
||||||
|
const state = getUserState();
|
||||||
|
if (requiresIdVerification(state) && !isIdVerified(profileIdVerified)) {
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
if (isIdVerified(profileIdVerified)) return "verified";
|
||||||
|
return "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessCapabilities(
|
||||||
|
isLoggedIn: boolean,
|
||||||
|
profileIdVerified?: boolean
|
||||||
|
): AccessCapabilities {
|
||||||
|
const tier = getAccessTier(isLoggedIn, profileIdVerified);
|
||||||
|
const state = getUserState();
|
||||||
|
const idOk = isIdVerified(profileIdVerified);
|
||||||
|
|
||||||
|
const canViewExplicit =
|
||||||
|
isLoggedIn && (!requiresIdVerification(state) || idOk);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tier,
|
||||||
|
canViewExplicit,
|
||||||
|
canSendMessages: isLoggedIn,
|
||||||
|
canUploadPhotos: isLoggedIn,
|
||||||
|
canUseQuickReplies: isLoggedIn,
|
||||||
|
canPostSpots: isLoggedIn,
|
||||||
|
canShareMedia: isLoggedIn && canViewExplicit,
|
||||||
|
label:
|
||||||
|
tier === "guest"
|
||||||
|
? "Guest — age verified"
|
||||||
|
: tier === "verified"
|
||||||
|
? "Verified member"
|
||||||
|
: "Member",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blur explicit content for guests OR members in ID-required states without verification */
|
||||||
|
export function shouldBlurExplicit(
|
||||||
|
isLoggedIn: boolean,
|
||||||
|
profileIdVerified?: boolean
|
||||||
|
): boolean {
|
||||||
|
return !getAccessCapabilities(isLoggedIn, profileIdVerified).canViewExplicit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAgeVerified(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return localStorage.getItem("eg_age_verified") === "true";
|
||||||
|
}
|
||||||
@@ -112,7 +112,7 @@ export function setIdVerified(verified: boolean): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when explicit/nude photos must stay blurred */
|
/** @deprecated Use shouldBlurExplicit from access-tier.ts */
|
||||||
export function mustBlurExplicit(profileVerified?: boolean): boolean {
|
export function mustBlurExplicit(profileVerified?: boolean): boolean {
|
||||||
const state = getUserState();
|
const state = getUserState();
|
||||||
if (!requiresIdVerification(state)) return false;
|
if (!requiresIdVerification(state)) return false;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Profile, CruisingSpot } from "@/types";
|
import type { Profile, CruisingSpot } from "@/types";
|
||||||
|
import { distanceMiles } from "@/lib/profile-stats";
|
||||||
|
|
||||||
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
|
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 DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
|
||||||
@@ -7,41 +8,75 @@ function jitter(base: number, range: number) {
|
|||||||
return base + (Math.random() - 0.5) * range;
|
return base + (Math.random() - 0.5) * range;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withDistance(p: Omit<Profile, "distance_miles">): Profile {
|
||||||
|
const lat = p.lat ?? DEFAULT_LAT;
|
||||||
|
const lng = p.lng ?? DEFAULT_LNG;
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
distance_miles: distanceMiles(DEFAULT_LAT, DEFAULT_LNG, lat, lng),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const MOCK_PROFILES: Profile[] = [
|
export const MOCK_PROFILES: Profile[] = [
|
||||||
{
|
withDistance({
|
||||||
id: "1",
|
id: "1",
|
||||||
username: "ftl_top",
|
username: "ftl_top",
|
||||||
display_name: "Mike",
|
display_name: "Mike",
|
||||||
age: 32,
|
age: 31,
|
||||||
position: "top",
|
position: "top",
|
||||||
bio: "Hosting tonight near Wilton Drive. HMU.",
|
bio: "Hosting tonight near Wilton Drive. HMU.",
|
||||||
avatar_url: "https://placehold.co/200x200/1a1220/3b82f6?text=Top",
|
avatar_url: "https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=200&h=200&fit=crop",
|
||||||
status: "hosting",
|
status: "hosting",
|
||||||
is_anonymous: false,
|
is_anonymous: false,
|
||||||
kinks: ["oral", "rimming", "daddy"],
|
is_verified: true,
|
||||||
sti_status: "Negative · tested Jan 2026",
|
kinks: ["oral", "rimming", "daddy", "exhibitionism"],
|
||||||
|
sti_status: "Negative, On PrEP",
|
||||||
on_prep: true,
|
on_prep: true,
|
||||||
last_active: new Date().toISOString(),
|
last_active: new Date().toISOString(),
|
||||||
lat: jitter(DEFAULT_LAT, 0.02),
|
lat: jitter(DEFAULT_LAT, 0.02),
|
||||||
lng: jitter(DEFAULT_LNG, 0.02),
|
lng: jitter(DEFAULT_LNG, 0.02),
|
||||||
city: "Fort Lauderdale",
|
city: "Fort Lauderdale",
|
||||||
state: "FL",
|
state: "FL",
|
||||||
id_verified: false,
|
id_verified: true,
|
||||||
id_verified_at: null,
|
id_verified_at: new Date().toISOString(),
|
||||||
vanilla_mode: false,
|
vanilla_mode: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
},
|
height_in: 74,
|
||||||
{
|
weight_lb: 180,
|
||||||
|
body_type: "average",
|
||||||
|
endowment: `7" cut`,
|
||||||
|
orientation: "gay",
|
||||||
|
headline: "No Pic, No Chat",
|
||||||
|
into_public: ["Outdoors", "Parks"],
|
||||||
|
fetishes: ["Armpits", "Ass", "Feet", "Jockstraps"],
|
||||||
|
practices: ["Bareback Only"],
|
||||||
|
interaction: ["Anonymous", "Cum and Go"],
|
||||||
|
hiv_status: "Negative, On PrEP",
|
||||||
|
hiv_tested: "May 1, 2026",
|
||||||
|
sti_tested: "May 1, 2026",
|
||||||
|
safeguards: ["COVID Vaccinated", "Mpox Vaccinated / Immune"],
|
||||||
|
comfort_levels: ["No Tobacco", "No Alcohol", "No PnP"],
|
||||||
|
carries: ["Lube"],
|
||||||
|
hosting: "Can host",
|
||||||
|
gallery: [
|
||||||
|
{ url: "https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
{ url: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
{ url: "https://placehold.co/400x500/1a1220/ff2d6b?text=Explicit", is_explicit: true },
|
||||||
|
{ url: "https://placehold.co/400x500/1a1220/8b2fc9?text=Explicit", is_explicit: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withDistance({
|
||||||
id: "2",
|
id: "2",
|
||||||
username: null,
|
username: null,
|
||||||
display_name: null,
|
display_name: null,
|
||||||
age: 28,
|
age: 28,
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
bio: "At the beach. Looking now.",
|
bio: "At the beach. Looking now.",
|
||||||
avatar_url: "https://placehold.co/200x200/1a1220/f97316?text=Bot",
|
avatar_url: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=200&h=200&fit=crop",
|
||||||
status: "looking",
|
status: "looking",
|
||||||
is_anonymous: true,
|
is_anonymous: true,
|
||||||
kinks: ["anon", "oral", "group"],
|
is_verified: false,
|
||||||
|
kinks: ["anon", "oral", "group", "gloryholes"],
|
||||||
sti_status: null,
|
sti_status: null,
|
||||||
on_prep: false,
|
on_prep: false,
|
||||||
last_active: new Date(Date.now() - 120000).toISOString(),
|
last_active: new Date(Date.now() - 120000).toISOString(),
|
||||||
@@ -53,18 +88,34 @@ export const MOCK_PROFILES: Profile[] = [
|
|||||||
id_verified_at: null,
|
id_verified_at: null,
|
||||||
vanilla_mode: false,
|
vanilla_mode: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
},
|
height_in: 69,
|
||||||
{
|
weight_lb: 165,
|
||||||
|
body_type: "fit",
|
||||||
|
endowment: `8" uncut`,
|
||||||
|
orientation: "gay",
|
||||||
|
headline: "Into guys around my age",
|
||||||
|
into_public: ["Parks", "Gyms"],
|
||||||
|
fetishes: ["Socks"],
|
||||||
|
practices: [],
|
||||||
|
interaction: ["Anonymous"],
|
||||||
|
hosting: "Can travel",
|
||||||
|
gallery: [
|
||||||
|
{ url: "https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
{ url: "https://placehold.co/400x500/1a1220/ff2d6b?text=Explicit", is_explicit: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withDistance({
|
||||||
id: "3",
|
id: "3",
|
||||||
username: "vers_bear",
|
username: "vers_bear",
|
||||||
display_name: "Jake",
|
display_name: "Jake",
|
||||||
age: 45,
|
age: 45,
|
||||||
position: "vers",
|
position: "vers",
|
||||||
bio: "Bear. Leather friendly. Gym rat.",
|
bio: "Bear. Leather friendly. Gym rat.",
|
||||||
avatar_url: "https://placehold.co/200x200/1a1220/a855f7?text=Vers",
|
avatar_url: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&h=200&fit=crop",
|
||||||
status: "online",
|
status: "online",
|
||||||
is_anonymous: false,
|
is_anonymous: false,
|
||||||
kinks: ["leather", "bdsm", "oral"],
|
is_verified: true,
|
||||||
|
kinks: ["leather", "bdsm", "oral", "fisting"],
|
||||||
sti_status: "Negative",
|
sti_status: "Negative",
|
||||||
on_prep: true,
|
on_prep: true,
|
||||||
last_active: new Date(Date.now() - 300000).toISOString(),
|
last_active: new Date(Date.now() - 300000).toISOString(),
|
||||||
@@ -72,21 +123,39 @@ export const MOCK_PROFILES: Profile[] = [
|
|||||||
lng: jitter(DEFAULT_LNG, 0.025),
|
lng: jitter(DEFAULT_LNG, 0.025),
|
||||||
city: "Fort Lauderdale",
|
city: "Fort Lauderdale",
|
||||||
state: "FL",
|
state: "FL",
|
||||||
id_verified: false,
|
id_verified: true,
|
||||||
id_verified_at: null,
|
id_verified_at: null,
|
||||||
vanilla_mode: false,
|
vanilla_mode: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
},
|
height_in: 71,
|
||||||
{
|
weight_lb: 210,
|
||||||
|
body_type: "stocky",
|
||||||
|
endowment: `6.5" cut`,
|
||||||
|
orientation: "gay",
|
||||||
|
headline: "Leather & beer",
|
||||||
|
fetishes: ["Leather", "Bondage"],
|
||||||
|
practices: ["Condoms OK"],
|
||||||
|
interaction: ["Drop Pants"],
|
||||||
|
hiv_status: "Negative, On PrEP",
|
||||||
|
hiv_tested: "Jan 2026",
|
||||||
|
sti_tested: "Jan 2026",
|
||||||
|
safeguards: ["COVID Vaccinated"],
|
||||||
|
comfort_levels: ["No PnP"],
|
||||||
|
gallery: [
|
||||||
|
{ url: "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withDistance({
|
||||||
id: "4",
|
id: "4",
|
||||||
username: "pup_play",
|
username: "pup_play",
|
||||||
display_name: "Rex",
|
display_name: "Rex",
|
||||||
age: 26,
|
age: 26,
|
||||||
position: "side",
|
position: "side",
|
||||||
bio: "Pup scene. Woof.",
|
bio: "Pup scene. Woof.",
|
||||||
avatar_url: "https://placehold.co/200x200/1a1220/22c55e?text=Side",
|
avatar_url: "https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=200&h=200&fit=crop",
|
||||||
status: "looking",
|
status: "looking",
|
||||||
is_anonymous: false,
|
is_anonymous: false,
|
||||||
|
is_verified: false,
|
||||||
kinks: ["pup", "bondage", "roleplay"],
|
kinks: ["pup", "bondage", "roleplay"],
|
||||||
sti_status: null,
|
sti_status: null,
|
||||||
on_prep: true,
|
on_prep: true,
|
||||||
@@ -99,17 +168,27 @@ export const MOCK_PROFILES: Profile[] = [
|
|||||||
id_verified_at: null,
|
id_verified_at: null,
|
||||||
vanilla_mode: false,
|
vanilla_mode: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
},
|
height_in: 68,
|
||||||
{
|
weight_lb: 155,
|
||||||
|
body_type: "slim",
|
||||||
|
orientation: "gay",
|
||||||
|
headline: "Woof",
|
||||||
|
hosting: "Can travel",
|
||||||
|
gallery: [
|
||||||
|
{ url: "https://images.unsplash.com/photo-1519085360753-af0119f7cbe7?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withDistance({
|
||||||
id: "5",
|
id: "5",
|
||||||
username: "traveler_mia",
|
username: "traveler_mia",
|
||||||
display_name: "Alex",
|
display_name: "Alex",
|
||||||
age: 35,
|
age: 35,
|
||||||
position: "vers",
|
position: "vers",
|
||||||
bio: "Visiting from Miami. Here till Sunday.",
|
bio: "Visiting from Miami. Here till Sunday.",
|
||||||
avatar_url: "https://placehold.co/200x200/1a1220/a855f7?text=Vers",
|
avatar_url: "https://images.unsplash.com/photo-1492562080023-ab3db95bfbce?w=200&h=200&fit=crop",
|
||||||
status: "traveling",
|
status: "traveling",
|
||||||
is_anonymous: false,
|
is_anonymous: false,
|
||||||
|
is_verified: true,
|
||||||
kinks: ["cruising", "anon", "oral"],
|
kinks: ["cruising", "anon", "oral"],
|
||||||
sti_status: "Negative · tested Dec 2025",
|
sti_status: "Negative · tested Dec 2025",
|
||||||
on_prep: true,
|
on_prep: true,
|
||||||
@@ -122,7 +201,82 @@ export const MOCK_PROFILES: Profile[] = [
|
|||||||
id_verified_at: null,
|
id_verified_at: null,
|
||||||
vanilla_mode: false,
|
vanilla_mode: false,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
},
|
height_in: 72,
|
||||||
|
weight_lb: 175,
|
||||||
|
body_type: "athletic",
|
||||||
|
endowment: `7.5" cut`,
|
||||||
|
orientation: "gay",
|
||||||
|
headline: "Miami visitor",
|
||||||
|
hosting: "Can travel",
|
||||||
|
gallery: [
|
||||||
|
{ url: "https://images.unsplash.com/photo-1492562080023-ab3db95bfbce?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
{ url: "https://placehold.co/400x500/1a1220/ff2d6b?text=Explicit", is_explicit: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withDistance({
|
||||||
|
id: "6",
|
||||||
|
username: "gym_sauna",
|
||||||
|
display_name: "Chris",
|
||||||
|
age: 33,
|
||||||
|
position: "bottom",
|
||||||
|
bio: "LA Fitness sauna regular.",
|
||||||
|
avatar_url: "https://images.unsplash.com/photo-1539571696357-5a90c39a553c?w=200&h=200&fit=crop",
|
||||||
|
status: "online",
|
||||||
|
is_anonymous: false,
|
||||||
|
is_verified: false,
|
||||||
|
kinks: ["oral", "underwear", "cruising"],
|
||||||
|
sti_status: null,
|
||||||
|
on_prep: true,
|
||||||
|
last_active: new Date(Date.now() - 45000).toISOString(),
|
||||||
|
lat: jitter(DEFAULT_LAT, 0.018),
|
||||||
|
lng: jitter(DEFAULT_LNG, 0.022),
|
||||||
|
city: "Fort Lauderdale",
|
||||||
|
state: "FL",
|
||||||
|
id_verified: false,
|
||||||
|
id_verified_at: null,
|
||||||
|
vanilla_mode: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
height_in: 70,
|
||||||
|
weight_lb: 170,
|
||||||
|
body_type: "fit",
|
||||||
|
orientation: "gay",
|
||||||
|
gallery: [
|
||||||
|
{ url: "https://images.unsplash.com/photo-1539571696357-5a90c39a553c?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
withDistance({
|
||||||
|
id: "7",
|
||||||
|
username: "beach_vers",
|
||||||
|
display_name: "Danny",
|
||||||
|
age: 29,
|
||||||
|
position: "vers",
|
||||||
|
bio: "Sebastian beach afternoons.",
|
||||||
|
avatar_url: "https://images.unsplash.com/photo-1504257432389-52343af06da3?w=200&h=200&fit=crop",
|
||||||
|
status: "looking",
|
||||||
|
is_anonymous: false,
|
||||||
|
is_verified: true,
|
||||||
|
kinks: ["anon", "oral", "exhibitionism"],
|
||||||
|
sti_status: "Negative",
|
||||||
|
on_prep: false,
|
||||||
|
last_active: new Date(Date.now() - 90000).toISOString(),
|
||||||
|
lat: jitter(DEFAULT_LAT, 0.028),
|
||||||
|
lng: jitter(DEFAULT_LNG, 0.012),
|
||||||
|
city: "Fort Lauderdale",
|
||||||
|
state: "FL",
|
||||||
|
id_verified: true,
|
||||||
|
id_verified_at: null,
|
||||||
|
vanilla_mode: false,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
height_in: 69,
|
||||||
|
weight_lb: 160,
|
||||||
|
body_type: "average",
|
||||||
|
orientation: "gay",
|
||||||
|
into_public: ["Outdoors", "Beaches"],
|
||||||
|
gallery: [
|
||||||
|
{ url: "https://images.unsplash.com/photo-1504257432389-52343af06da3?w=400&h=500&fit=crop", is_explicit: false },
|
||||||
|
{ url: "https://placehold.co/400x500/1a1220/ff2d6b?text=Explicit", is_explicit: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MOCK_SPOTS: CruisingSpot[] = [
|
export const MOCK_SPOTS: CruisingSpot[] = [
|
||||||
@@ -167,9 +321,9 @@ export const MOCK_SPOTS: CruisingSpot[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "s4",
|
id: "s4",
|
||||||
name: "Ramrod Bar Restroom",
|
name: "Ramrod Bar",
|
||||||
description: "Back bar area. Weekend nights.",
|
description: "Back bar area. Weekend nights.",
|
||||||
category: "restroom",
|
category: "club",
|
||||||
lat: 26.1603,
|
lat: 26.1603,
|
||||||
lng: -80.1389,
|
lng: -80.1389,
|
||||||
city: "Fort Lauderdale",
|
city: "Fort Lauderdale",
|
||||||
@@ -180,6 +334,32 @@ export const MOCK_SPOTS: CruisingSpot[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "s5",
|
id: "s5",
|
||||||
|
name: "Eagle Ft Lauderdale",
|
||||||
|
description: "Leather bar. Active weekends.",
|
||||||
|
category: "club",
|
||||||
|
lat: 26.1588,
|
||||||
|
lng: -80.1412,
|
||||||
|
city: "Fort Lauderdale",
|
||||||
|
rating: 4.1,
|
||||||
|
active_count: 6,
|
||||||
|
is_popular: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s6",
|
||||||
|
name: "Hunter's Bar",
|
||||||
|
description: "Neighborhood gay bar.",
|
||||||
|
category: "club",
|
||||||
|
lat: 26.1555,
|
||||||
|
lng: -80.1365,
|
||||||
|
city: "Wilton Manors",
|
||||||
|
rating: 4.0,
|
||||||
|
active_count: 5,
|
||||||
|
is_popular: true,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s7",
|
||||||
name: "Triple Crown Bookstore",
|
name: "Triple Crown Bookstore",
|
||||||
description: "Adult bookstore with viewing booths.",
|
description: "Adult bookstore with viewing booths.",
|
||||||
category: "bookstore",
|
category: "bookstore",
|
||||||
|
|||||||
44
src/lib/profile-stats.ts
Normal file
44
src/lib/profile-stats.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Profile } from "@/types";
|
||||||
|
|
||||||
|
export function formatHeight(inches?: number | null): string | null {
|
||||||
|
if (!inches) return null;
|
||||||
|
const ft = Math.floor(inches / 12);
|
||||||
|
const inch = inches % 12;
|
||||||
|
return `${ft}'${inch}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStatsLine(p: Profile): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (p.age) parts.push(`${p.age}m`);
|
||||||
|
const h = formatHeight(p.height_in);
|
||||||
|
if (h) parts.push(h);
|
||||||
|
if (p.weight_lb) parts.push(`${p.weight_lb}lb`);
|
||||||
|
if (p.endowment) parts.push(p.endowment);
|
||||||
|
if (p.body_type) parts.push(p.body_type);
|
||||||
|
if (p.orientation) parts.push(p.orientation);
|
||||||
|
if (p.position) parts.push(p.position);
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distanceMiles(
|
||||||
|
lat1: number,
|
||||||
|
lng1: number,
|
||||||
|
lat2: number,
|
||||||
|
lng2: number
|
||||||
|
): number {
|
||||||
|
const R = 3958.8;
|
||||||
|
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||||
|
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos((lat1 * Math.PI) / 180) *
|
||||||
|
Math.cos((lat2 * Math.PI) / 180) *
|
||||||
|
Math.sin(dLng / 2) ** 2;
|
||||||
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDistance(miles: number): string {
|
||||||
|
if (miles < 0.1) return "< 0.1 miles";
|
||||||
|
if (miles < 1) return `${miles.toFixed(2)} miles`;
|
||||||
|
return `${miles.toFixed(2)} miles`;
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ export type Position = "top" | "bottom" | "vers" | "side";
|
|||||||
|
|
||||||
export type UserStatus = "online" | "looking" | "hosting" | "traveling" | "offline";
|
export type UserStatus = "online" | "looking" | "hosting" | "traveling" | "offline";
|
||||||
|
|
||||||
|
export interface ProfilePhoto {
|
||||||
|
url: string;
|
||||||
|
is_explicit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
@@ -12,6 +17,7 @@ export interface Profile {
|
|||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
is_anonymous: boolean;
|
is_anonymous: boolean;
|
||||||
|
is_verified?: boolean;
|
||||||
kinks: string[];
|
kinks: string[];
|
||||||
sti_status: string | null;
|
sti_status: string | null;
|
||||||
on_prep: boolean;
|
on_prep: boolean;
|
||||||
@@ -24,6 +30,26 @@ export interface Profile {
|
|||||||
id_verified_at: string | null;
|
id_verified_at: string | null;
|
||||||
vanilla_mode: boolean;
|
vanilla_mode: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** Sniffies-style extended stats */
|
||||||
|
height_in?: number | null;
|
||||||
|
weight_lb?: number | null;
|
||||||
|
body_type?: string | null;
|
||||||
|
endowment?: string | null;
|
||||||
|
orientation?: string | null;
|
||||||
|
headline?: string | null;
|
||||||
|
into_public?: string[];
|
||||||
|
fetishes?: string[];
|
||||||
|
practices?: string[];
|
||||||
|
interaction?: string[];
|
||||||
|
hiv_status?: string | null;
|
||||||
|
hiv_tested?: string | null;
|
||||||
|
sti_tested?: string | null;
|
||||||
|
safeguards?: string[];
|
||||||
|
comfort_levels?: string[];
|
||||||
|
carries?: string[];
|
||||||
|
hosting?: string | null;
|
||||||
|
gallery?: ProfilePhoto[];
|
||||||
|
distance_miles?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GalleryAlbum {
|
export interface GalleryAlbum {
|
||||||
|
|||||||
Reference in New Issue
Block a user