Add audience views: gay men, lesbian, trans with smart cross-visibility and entry picker

This commit is contained in:
Ryan Salazar
2026-06-26 22:04:00 -04:00
parent ff6db29cab
commit dd075da6a4
14 changed files with 660 additions and 10 deletions

View File

@@ -331,4 +331,23 @@
.animate-interest-ticker { .animate-interest-ticker {
animation: interest-ticker 28s linear infinite; animation: interest-ticker 28s linear infinite;
}
/* Audience view accents */
[data-audience="lesbian"] .gradient-text {
background: linear-gradient(135deg, #c084fc 0%, #f472b6 50%, #c084fc 100%);
-webkit-background-clip: text;
background-clip: text;
}
[data-audience="trans"] .gradient-text {
background: linear-gradient(135deg, #5eead4 0%, #c084fc 50%, #f472b6 100%);
-webkit-background-clip: text;
background-clip: text;
}
[data-audience="bi"] .gradient-text {
background: linear-gradient(135deg, #f472b6 0%, #60a5fa 50%, #c084fc 100%);
-webkit-background-clip: text;
background-clip: text;
} }

View File

@@ -10,7 +10,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ShieldCheck, MapPin } from "lucide-react"; import { ShieldCheck, MapPin } from "lucide-react";
import { INTEREST_OPTIONS } from "@/lib/interests"; import { INTEREST_OPTIONS } from "@/lib/interests";
import { InterestTicker } from "@/components/InterestTicker"; import { InterestTicker } from "@/components/InterestTicker";
import type { Position } from "@/types"; import type { Position, AudienceCommunity, SeekingPreference } from "@/types";
import {
COMMUNITY_OPTIONS,
SEEKING_OPTIONS,
resolveProfileCommunity,
} from "@/lib/audience";
import { AudiencePicker } from "@/components/AudiencePicker";
import { useAuth } from "@/lib/auth/context"; import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal"; import { AuthModal } from "@/components/AuthModal";
import { MediaGalleryManager } from "@/components/MediaGalleryManager"; import { MediaGalleryManager } from "@/components/MediaGalleryManager";
@@ -18,7 +24,7 @@ import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
import { IdVerificationModal } from "@/components/IdVerificationModal"; import { IdVerificationModal } from "@/components/IdVerificationModal";
import { LocalGuidePanel } from "@/components/LocalGuidePanel"; import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { useAccess, useFeedPreferences } from "@/components/Providers"; import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Providers";
import { import {
US_STATES, US_STATES,
getUserState, getUserState,
@@ -32,6 +38,7 @@ export default function ProfilePage() {
const { user, profile, loading, updateProfile, signOut } = useAuth(); const { user, profile, loading, updateProfile, signOut } = useAuth();
const { capabilities } = useAccess(); const { capabilities } = useAccess();
const { setFeedInterests } = useFeedPreferences(); const { setFeedInterests } = useFeedPreferences();
const { audienceView, setAudienceView } = useAudienceView();
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);
@@ -45,6 +52,8 @@ export default function ProfilePage() {
const [state, setState] = useState(""); const [state, setState] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [community, setCommunity] = useState<AudienceCommunity>("gay-men");
const [seeking, setSeeking] = useState<SeekingPreference | null>(null);
useEffect(() => { useEffect(() => {
if (profile) { if (profile) {
@@ -59,6 +68,8 @@ export default function ProfilePage() {
setOnPrep(profile.on_prep); setOnPrep(profile.on_prep);
setStiStatus(profile.sti_status || ""); setStiStatus(profile.sti_status || "");
setState(profile.state || getUserState() || ""); setState(profile.state || getUserState() || "");
setCommunity(profile.community ?? resolveProfileCommunity(profile));
setSeeking(profile.seeking ?? null);
} else { } else {
const local = localStorage.getItem("eg_profile"); const local = localStorage.getItem("eg_profile");
setState(getUserState() || ""); setState(getUserState() || "");
@@ -73,6 +84,8 @@ export default function ProfilePage() {
setKinks(p.kinks || []); setKinks(p.kinks || []);
setOnPrep(p.onPrep || false); setOnPrep(p.onPrep || false);
setStiStatus(p.stiStatus || ""); setStiStatus(p.stiStatus || "");
if (p.community) setCommunity(p.community);
if (p.seeking) setSeeking(p.seeking);
} catch { } catch {
/* ignore */ /* ignore */
} }
@@ -104,6 +117,10 @@ export default function ProfilePage() {
sti_status: stiStatus || null, sti_status: stiStatus || null,
is_anonymous: isAnonymous, is_anonymous: isAnonymous,
state: state || null, state: state || null,
community,
seeking:
community === "trans" || community === "nonbinary" ? seeking : null,
orientation: community === "gay-men" ? "gay" : community,
status: "online" as const, status: "online" as const,
city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale", city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale",
lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"), lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"),
@@ -125,6 +142,8 @@ export default function ProfilePage() {
onPrep: data.on_prep, onPrep: data.on_prep,
stiStatus: data.sti_status, stiStatus: data.sti_status,
isAnonymous: data.is_anonymous, isAnonymous: data.is_anonymous,
community: data.community,
seeking: data.seeking,
}) })
); );
setMessage("Profile saved locally. Sign up free for cloud sync."); setMessage("Profile saved locally. Sign up free for cloud sync.");
@@ -240,6 +259,67 @@ export default function ProfilePage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Community</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="mb-2 block">I am / my community</Label>
<div className="flex flex-wrap gap-1.5">
{COMMUNITY_OPTIONS.map((opt) => (
<Badge
key={opt.id}
variant={community === opt.id ? "pill-active" : "pill"}
className="cursor-pointer min-h-[36px] px-3 touch-manipulation"
onClick={() => {
setCommunity(opt.id);
if (opt.id !== "trans" && opt.id !== "nonbinary") {
setSeeking(null);
} else if (!seeking) {
setSeeking("men");
}
}}
>
{opt.label}
</Badge>
))}
</div>
</div>
{(community === "trans" || community === "nonbinary") && (
<div>
<Label className="mb-2 block">I&apos;m mainly interested in</Label>
<p className="text-[10px] text-muted-foreground mb-2">
Controls whether you appear in the gay-men or lesbian map view.
Trans view always shows you.
</p>
<div className="flex flex-wrap gap-1.5">
{SEEKING_OPTIONS.map((opt) => (
<Badge
key={opt.id}
variant={seeking === opt.id ? "pill-active" : "pill"}
className="cursor-pointer min-h-[36px] px-3 touch-manipulation"
onClick={() => setSeeking(opt.id)}
>
{opt.label}
</Badge>
))}
</div>
</div>
)}
<div>
<Label className="mb-2 block">Map view who you want to see</Label>
<AudiencePicker
value={audienceView}
onChange={setAudienceView}
compact
/>
</div>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Details</CardTitle> <CardTitle className="text-lg">Details</CardTitle>

View File

@@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { US_STATES, setUserState, requiresIdVerification } from "@/lib/id-verification"; import { US_STATES, setUserState, requiresIdVerification } from "@/lib/id-verification";
import { AudiencePicker } from "@/components/AudiencePicker";
import { saveAudienceView, formatSiteTitle, type AudienceViewMode } from "@/lib/audience";
interface AgeGateProps { interface AgeGateProps {
onVerified: () => void; onVerified: () => void;
@@ -17,6 +19,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
const [state, setState] = useState(""); const [state, setState] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [audienceView, setAudienceView] = useState<AudienceViewMode>("gay-men");
const handleVerify = () => { const handleVerify = () => {
const m = parseInt(month, 10); const m = parseInt(month, 10);
@@ -51,6 +54,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
return; return;
} }
saveAudienceView(audienceView);
localStorage.setItem("eg_age_verified", "true"); localStorage.setItem("eg_age_verified", "true");
localStorage.setItem("eg_age_verified_at", new Date().toISOString()); localStorage.setItem("eg_age_verified_at", new Date().toISOString());
setUserState(state); setUserState(state);
@@ -74,7 +78,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
<Logo size="lg" showText={false} /> <Logo size="lg" showText={false} />
</div> </div>
<h1 className="text-2xl font-bold gradient-text"> <h1 className="text-2xl font-bold gradient-text">
ExposedGays {formatSiteTitle(audienceView)}
</h1> </h1>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Adults only. Explicit content ahead. Adults only. Explicit content ahead.
@@ -149,6 +153,15 @@ export function AgeGate({ onVerified }: AgeGateProps) {
)} )}
</div> </div>
<div>
<Label className="mb-2 block">Who do you want to see?</Label>
<AudiencePicker
value={audienceView}
onChange={setAudienceView}
compact
/>
</div>
<div className="rounded-xl border border-primary/25 bg-primary/10 p-3"> <div className="rounded-xl border border-primary/25 bg-primary/10 p-3">
<p className="text-xs text-foreground/90 leading-relaxed"> <p className="text-xs text-foreground/90 leading-relaxed">
<strong className="text-pulse-pink-light">No account needed.</strong> Verify your age <strong className="text-pulse-pink-light">No account needed.</strong> Verify your age

View File

@@ -0,0 +1,60 @@
"use client";
import { AUDIENCE_VIEW_OPTIONS, type AudienceViewMode } from "@/lib/audience";
import { cn } from "@/lib/utils";
interface AudiencePickerProps {
value: AudienceViewMode;
onChange: (mode: AudienceViewMode) => void;
compact?: boolean;
className?: string;
}
export function AudiencePicker({
value,
onChange,
compact,
className,
}: AudiencePickerProps) {
return (
<div className={cn("space-y-2", className)}>
{!compact && (
<p className="text-xs text-muted-foreground leading-relaxed">
Pick who you want on your map. Change anytime from the header. Trans folks
can appear in multiple views based on who they&apos;re into.
</p>
)}
<div className={cn("flex flex-wrap gap-2", compact && "gap-1.5")}>
{AUDIENCE_VIEW_OPTIONS.map((opt) => {
const active = value === opt.id;
return (
<button
key={opt.id}
type="button"
onClick={() => onChange(opt.id)}
className={cn(
"rounded-full border text-left transition-all touch-manipulation",
compact ? "px-3 py-1.5 text-xs" : "px-4 py-2.5 text-sm min-w-[46%] flex-1",
active
? "border-primary/50 bg-pulse-gradient-bold text-white shadow-pulse-glow font-bold"
: "pulse-pill-inactive"
)}
>
<span className="block font-semibold">{opt.label}</span>
{!compact && (
<span
className={cn(
"block text-[10px] mt-0.5 font-normal",
active ? "text-white/85" : "text-muted-foreground"
)}
>
{opt.description}
</span>
)}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { ChevronDown, Users } from "lucide-react";
import { AudiencePicker } from "@/components/AudiencePicker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAudienceView } from "@/components/Providers";
import { getAudienceOption } from "@/lib/audience";
import { cn } from "@/lib/utils";
interface AudienceSwitcherProps {
className?: string;
}
export function AudienceSwitcher({ className }: AudienceSwitcherProps) {
const { audienceView, setAudienceView } = useAudienceView();
const [open, setOpen] = useState(false);
const opt = getAudienceOption(audienceView);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={cn(
"inline-flex items-center gap-1 rounded-full border border-primary/25 bg-pulse-midnight/60 px-2.5 py-1 text-xs font-semibold text-pulse-pink-light hover:border-primary/40 transition-colors touch-manipulation",
className
)}
title="Change who you see on the map"
>
<Users className="h-3.5 w-3.5 shrink-0" />
<span className="truncate max-w-[88px]">{opt.shortLabel}</span>
<ChevronDown className="h-3 w-3 opacity-60" />
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Who do you want to see?</DialogTitle>
<DialogDescription>
Your map, previews, and nearby cruisers update instantly.
</DialogDescription>
</DialogHeader>
<AudiencePicker
value={audienceView}
onChange={(mode) => {
setAudienceView(mode);
setOpen(false);
}}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -15,7 +15,8 @@ import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests"; import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests";
import { InterestTicker } from "@/components/InterestTicker"; import { InterestTicker } from "@/components/InterestTicker";
import { InterestPreviewRail } from "@/components/InterestPreviewRail"; import { InterestPreviewRail } from "@/components/InterestPreviewRail";
import { useFeedPreferences } from "@/components/Providers"; import { useFeedPreferences, useAudienceView } from "@/components/Providers";
import { profileMatchesAudienceView } from "@/lib/audience";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data"; import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import { useOpenIdVerification, useAccess } from "@/components/Providers"; import { useOpenIdVerification, useAccess } from "@/components/Providers";
import { distanceMiles } from "@/lib/profile-stats"; import { distanceMiles } from "@/lib/profile-stats";
@@ -50,6 +51,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const openIdVerification = useOpenIdVerification(); const openIdVerification = useOpenIdVerification();
const { capabilities, blurExplicit } = useAccess(); const { capabilities, blurExplicit } = useAccess();
const { feedInterests } = useFeedPreferences(); const { feedInterests } = useFeedPreferences();
const { audienceView } = useAudienceView();
const [profiles] = useState<Profile[]>(MOCK_PROFILES); 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);
@@ -148,20 +150,22 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
return false; return false;
if (feedInterests.length && !profileMatchesInterests(p.kinks, feedInterests)) if (feedInterests.length && !profileMatchesInterests(p.kinks, feedInterests))
return false; return false;
if (!profileMatchesAudienceView(p, audienceView)) return false;
if (filters.onPrep === true && !p.on_prep) return false; if (filters.onPrep === true && !p.on_prep) return false;
if (filters.status.length && !filters.status.includes(p.status)) return false; if (filters.status.length && !filters.status.includes(p.status)) return false;
return true; return true;
}); });
}, [profiles, filters, feedInterests]); }, [profiles, filters, feedInterests, audienceView]);
const interestMatches = useMemo(() => { const interestMatches = useMemo(() => {
if (!feedInterests.length) return []; if (!feedInterests.length) return [];
return profiles.filter( return profiles.filter(
(p) => (p) =>
p.status !== "offline" && p.status !== "offline" &&
profileMatchesAudienceView(p, audienceView) &&
profileMatchesInterests(p.kinks, feedInterests) profileMatchesInterests(p.kinks, feedInterests)
); );
}, [profiles, feedInterests]); }, [profiles, feedInterests, audienceView]);
useEffect(() => { useEffect(() => {
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length); onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);

View File

@@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { AudienceSwitcher } from "@/components/AudienceSwitcher";
import { useAudienceView } from "@/components/Providers";
interface MobileHeaderProps { interface MobileHeaderProps {
vanillaMode: boolean; vanillaMode: boolean;
@@ -19,12 +21,14 @@ export function MobileHeader({
activeCount = 0, activeCount = 0,
isGuest, isGuest,
}: MobileHeaderProps) { }: MobileHeaderProps) {
const { siteTitle } = useAudienceView();
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">
<Link href="/" className="flex items-center gap-1.5 min-w-0"> <Link href="/" className="flex items-center gap-1.5 min-w-0">
<Logo size="sm" showText={false} /> <Logo size="sm" showText={false} />
<span className="text-sm font-bold gradient-text truncate"> <span className="text-sm font-bold gradient-text truncate">
ExposedGays {siteTitle}
</span> </span>
{activeCount > 0 && ( {activeCount > 0 && (
<Badge variant="online" className="text-[9px] shrink-0"> <Badge variant="online" className="text-[9px] shrink-0">
@@ -34,6 +38,7 @@ export function MobileHeader({
</Link> </Link>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<AudienceSwitcher />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@@ -11,6 +11,8 @@ import { useCartStore } from "@/lib/cart";
import { useAuth } from "@/lib/auth/context"; import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal"; import { AuthModal } from "@/components/AuthModal";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AudienceSwitcher } from "@/components/AudienceSwitcher";
import { useAudienceView } from "@/components/Providers";
interface NavProps { interface NavProps {
vanillaMode: boolean; vanillaMode: boolean;
@@ -41,12 +43,16 @@ export function Nav({
const itemCount = useCartStore((s) => s.itemCount()); const itemCount = useCartStore((s) => s.itemCount());
const { user, profile, signOut } = useAuth(); const { user, profile, signOut } = useAuth();
const [authOpen, setAuthOpen] = useState(false); const [authOpen, setAuthOpen] = useState(false);
const { siteTitle } = useAudienceView();
return ( return (
<> <>
<header className="hidden md:flex fixed top-0 left-0 right-0 z-50 h-14 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-6"> <header className="hidden md:flex fixed top-0 left-0 right-0 z-50 h-14 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-6">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2 min-w-0">
<Logo size="md" /> <Logo size="md" />
<span className="hidden lg:block text-sm font-bold gradient-text truncate max-w-[200px]">
{siteTitle}
</span>
{activeCount > 0 && ( {activeCount > 0 && (
<Badge variant="online" className="text-[10px]"> <Badge variant="online" className="text-[10px]">
{activeCount} active {activeCount} active
@@ -78,6 +84,7 @@ export function Nav({
</nav> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AudienceSwitcher className="hidden sm:inline-flex" />
{idBlur && onIdVerifyClick && ( {idBlur && onIdVerifyClick && (
<Button <Button
variant="outline" variant="outline"

View File

@@ -23,6 +23,12 @@ import {
mergeFeedWithProfile, mergeFeedWithProfile,
} from "@/lib/feed-preferences"; } from "@/lib/feed-preferences";
import { normalizeInterestId } from "@/lib/interests"; import { normalizeInterestId } from "@/lib/interests";
import {
loadAudienceView,
saveAudienceView,
formatSiteTitle,
type AudienceViewMode,
} from "@/lib/audience";
const ActiveCountContext = createContext<(n: number) => void>(() => {}); const ActiveCountContext = createContext<(n: number) => void>(() => {});
const VanillaModeContext = createContext(false); const VanillaModeContext = createContext(false);
@@ -54,6 +60,22 @@ export function useFeedPreferences() {
return useContext(FeedPreferencesContext); return useContext(FeedPreferencesContext);
} }
interface AudienceViewValue {
audienceView: AudienceViewMode;
setAudienceView: (mode: AudienceViewMode) => void;
siteTitle: string;
}
const AudienceViewContext = createContext<AudienceViewValue>({
audienceView: "gay-men",
setAudienceView: () => {},
siteTitle: "ExposedGays",
});
export function useAudienceView() {
return useContext(AudienceViewContext);
}
export function useSetActiveCount() { export function useSetActiveCount() {
return useContext(ActiveCountContext); return useContext(ActiveCountContext);
} }
@@ -85,6 +107,7 @@ function InnerProviders({ children }: ProvidersProps) {
const [activeCount, setActiveCount] = useState(0); const [activeCount, setActiveCount] = useState(0);
const [idModalOpen, setIdModalOpen] = useState(false); const [idModalOpen, setIdModalOpen] = useState(false);
const [feedInterests, setFeedInterestsState] = useState<string[]>([]); const [feedInterests, setFeedInterestsState] = useState<string[]>([]);
const [audienceView, setAudienceViewState] = useState<AudienceViewMode>("gay-men");
const isLoggedIn = !!user; const isLoggedIn = !!user;
const capabilities = useMemo( const capabilities = useMemo(
@@ -97,6 +120,9 @@ function InnerProviders({ children }: ProvidersProps) {
); );
useEffect(() => { useEffect(() => {
const view = loadAudienceView();
setAudienceViewState(view);
document.documentElement.dataset.audience = view;
setFeedInterestsState(loadFeedInterests()); setFeedInterestsState(loadFeedInterests());
setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true"); setVanillaMode(localStorage.getItem("eg_vanilla_mode") === "true");
@@ -104,8 +130,16 @@ function InnerProviders({ children }: ProvidersProps) {
const detail = (e as CustomEvent<string[]>).detail; const detail = (e as CustomEvent<string[]>).detail;
if (Array.isArray(detail)) setFeedInterestsState(detail); if (Array.isArray(detail)) setFeedInterestsState(detail);
}; };
const onAudience = (e: Event) => {
const detail = (e as CustomEvent<AudienceViewMode>).detail;
if (detail) setAudienceViewState(detail);
};
window.addEventListener("eg-feed-interests", onFeed); window.addEventListener("eg-feed-interests", onFeed);
return () => window.removeEventListener("eg-feed-interests", onFeed); window.addEventListener("eg-audience-view", onAudience);
return () => {
window.removeEventListener("eg-feed-interests", onFeed);
window.removeEventListener("eg-audience-view", onAudience);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -146,6 +180,13 @@ function InnerProviders({ children }: ProvidersProps) {
setFeedInterestsState(norm); setFeedInterestsState(norm);
}, []); }, []);
const setAudienceView = useCallback((mode: AudienceViewMode) => {
saveAudienceView(mode);
setAudienceViewState(mode);
}, []);
const siteTitle = useMemo(() => formatSiteTitle(audienceView), [audienceView]);
const toggleFeedInterest = useCallback((id: string) => { const toggleFeedInterest = useCallback((id: string) => {
const norm = normalizeInterestId(id); const norm = normalizeInterestId(id);
setFeedInterestsState((prev) => { setFeedInterestsState((prev) => {
@@ -162,6 +203,9 @@ function InnerProviders({ children }: ProvidersProps) {
<FeedPreferencesContext.Provider <FeedPreferencesContext.Provider
value={{ feedInterests, setFeedInterests, toggleFeedInterest }} value={{ feedInterests, setFeedInterests, toggleFeedInterest }}
> >
<AudienceViewContext.Provider
value={{ audienceView, setAudienceView, siteTitle }}
>
<AccessContext.Provider value={{ capabilities, blurExplicit }}> <AccessContext.Provider value={{ capabilities, blurExplicit }}>
<OpenIdVerificationContext.Provider value={openIdVerification}> <OpenIdVerificationContext.Provider value={openIdVerification}>
<Nav <Nav
@@ -194,6 +238,7 @@ function InnerProviders({ children }: ProvidersProps) {
/> />
</OpenIdVerificationContext.Provider> </OpenIdVerificationContext.Provider>
</AccessContext.Provider> </AccessContext.Provider>
</AudienceViewContext.Provider>
</FeedPreferencesContext.Provider> </FeedPreferencesContext.Provider>
</VanillaModeContext.Provider> </VanillaModeContext.Provider>
); );

176
src/lib/audience.ts Normal file
View File

@@ -0,0 +1,176 @@
import type {
Profile,
AudienceViewMode,
AudienceCommunity,
SeekingPreference,
} from "@/types";
export type { AudienceViewMode, AudienceCommunity, SeekingPreference };
export interface AudienceOption {
id: AudienceViewMode;
label: string;
shortLabel: string;
description: string;
siteTitle: string;
siteSubtitle?: string;
}
export const AUDIENCE_VIEW_OPTIONS: AudienceOption[] = [
{
id: "gay-men",
label: "Gay Men",
shortLabel: "Gay",
description: "Men into men — the classic cruising map",
siteTitle: "ExposedGays",
},
{
id: "lesbian",
label: "Lesbians",
shortLabel: "Lesbian",
description: "Women into women — lesbian cruising & meetups",
siteTitle: "ExposedGays",
siteSubtitle: "Lesbians",
},
{
id: "trans",
label: "Trans & NB",
shortLabel: "Trans",
description: "Trans & nonbinary folks plus who connects with them",
siteTitle: "ExposedGays",
siteSubtitle: "Trans",
},
{
id: "bi",
label: "Bi & Pan",
shortLabel: "Bi",
description: "Open to multiple genders — broader matches",
siteTitle: "ExposedGays",
siteSubtitle: "Bi & Pan",
},
{
id: "everyone",
label: "Everyone",
shortLabel: "All",
description: "No community filter — see the full map",
siteTitle: "ExposedGays",
siteSubtitle: "Everyone",
},
];
export const COMMUNITY_OPTIONS: {
id: AudienceCommunity;
label: string;
showSeeking?: boolean;
}[] = [
{ id: "gay-men", label: "Gay man" },
{ id: "lesbian", label: "Lesbian" },
{ id: "trans", label: "Trans", showSeeking: true },
{ id: "nonbinary", label: "Nonbinary", showSeeking: true },
{ id: "bi", label: "Bi / Pan" },
{ id: "queer", label: "Queer" },
];
export const SEEKING_OPTIONS: { id: SeekingPreference; label: string }[] = [
{ id: "men", label: "Mostly men" },
{ id: "women", label: "Mostly women" },
{ id: "everyone", label: "Everyone" },
];
const LS_VIEW = "eg_audience_view";
export function loadAudienceView(): AudienceViewMode {
if (typeof window === "undefined") return "gay-men";
const raw = localStorage.getItem(LS_VIEW);
if (AUDIENCE_VIEW_OPTIONS.some((o) => o.id === raw)) {
return raw as AudienceViewMode;
}
return "gay-men";
}
export function saveAudienceView(mode: AudienceViewMode): void {
if (typeof window === "undefined") return;
localStorage.setItem(LS_VIEW, mode);
document.documentElement.dataset.audience = mode;
window.dispatchEvent(new CustomEvent("eg-audience-view", { detail: mode }));
}
export function getAudienceOption(mode: AudienceViewMode): AudienceOption {
return AUDIENCE_VIEW_OPTIONS.find((o) => o.id === mode) ?? AUDIENCE_VIEW_OPTIONS[0];
}
export function formatSiteTitle(mode: AudienceViewMode): string {
const opt = getAudienceOption(mode);
if (!opt.siteSubtitle) return opt.siteTitle;
return `${opt.siteTitle} · ${opt.siteSubtitle}`;
}
/** Map legacy orientation string + optional community field */
export function resolveProfileCommunity(profile: Profile): AudienceCommunity {
if (profile.community) return profile.community;
const o = (profile.orientation ?? "").toLowerCase();
if (o === "lesbian" || o === "wlw") return "lesbian";
if (o === "trans" || o === "transgender") return "trans";
if (o === "nonbinary" || o === "nb" || o === "enby") return "nonbinary";
if (o === "bi" || o === "bisexual" || o === "pan") return "bi";
if (o === "gay" || o === "gay man" || o === "homosexual") return "gay-men";
return "gay-men";
}
export function resolveProfileSeeking(profile: Profile): SeekingPreference | null {
return profile.seeking ?? null;
}
/**
* Trans visibility rules:
* - Trans view: all trans + nonbinary profiles
* - Gay men view: gay-men, bi, + trans/NB who seek men (or everyone, or unset → defaults to men)
* - Lesbian view: lesbians + trans/NB who seek women or everyone
* - Bi view: bi, queer, gay-men, lesbian, trans, nonbinary
* - Everyone: no filter
*/
export function profileMatchesAudienceView(
profile: Profile,
view: AudienceViewMode
): boolean {
if (view === "everyone") return true;
const community = resolveProfileCommunity(profile);
const seeking = resolveProfileSeeking(profile);
const isTransSpectrum =
community === "trans" || community === "nonbinary";
switch (view) {
case "gay-men":
if (community === "gay-men") return true;
if (community === "bi" || community === "queer") return true;
if (isTransSpectrum) {
if (seeking === "women") return false;
return seeking === "men" || seeking === "everyone" || seeking === null;
}
return false;
case "lesbian":
if (community === "lesbian") return true;
if (isTransSpectrum) {
return seeking === "women" || seeking === "everyone";
}
return false;
case "trans":
return isTransSpectrum || community === "queer";
case "bi":
return (
community === "bi" ||
community === "queer" ||
community === "gay-men" ||
community === "lesbian" ||
isTransSpectrum
);
default:
return true;
}
}

View File

@@ -51,9 +51,31 @@ function mapDbProfile(row: Record<string, unknown>): Profile {
id_verified_at: (row.id_verified_at as string) ?? null, id_verified_at: (row.id_verified_at as string) ?? null,
vanilla_mode: (row.vanilla_mode as boolean) ?? false, vanilla_mode: (row.vanilla_mode as boolean) ?? false,
created_at: (row.created_at as string) ?? new Date().toISOString(), created_at: (row.created_at as string) ?? new Date().toISOString(),
orientation: (row.orientation as string) ?? null,
community: (row.community as Profile["community"]) ?? null,
seeking: (row.seeking as Profile["seeking"]) ?? null,
}; };
} }
function loadProfileMeta(userId: string): Partial<Profile> {
if (typeof window === "undefined") return {};
try {
const raw = localStorage.getItem(`eg_profile_meta_${userId}`);
return raw ? (JSON.parse(raw) as Partial<Profile>) : {};
} catch {
return {};
}
}
function saveProfileMeta(userId: string, data: Partial<Profile>) {
if (typeof window === "undefined") return;
const existing = loadProfileMeta(userId);
localStorage.setItem(
`eg_profile_meta_${userId}`,
JSON.stringify({ ...existing, community: data.community, seeking: data.seeking, orientation: data.orientation })
);
}
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const supabase = createClient(); const supabase = createClient();
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
@@ -70,7 +92,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
.single(); .single();
if (!error && data) { if (!error && data) {
setProfile(mapDbProfile(data)); const mapped = mapDbProfile(data);
const meta = loadProfileMeta(userId);
setProfile({ ...mapped, ...meta });
} }
}, },
[supabase] [supabase]
@@ -145,6 +169,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (data.state !== undefined) payload.state = data.state; if (data.state !== undefined) payload.state = data.state;
if (data.id_verified !== undefined) payload.id_verified = data.id_verified; if (data.id_verified !== undefined) payload.id_verified = data.id_verified;
if (data.id_verified_at !== undefined) payload.id_verified_at = data.id_verified_at; if (data.id_verified_at !== undefined) payload.id_verified_at = data.id_verified_at;
if (data.orientation !== undefined) payload.orientation = data.orientation;
saveProfileMeta(user.id, {
community: data.community,
seeking: data.seeking,
orientation: data.orientation,
});
const { error } = await supabase const { error } = await supabase
.from("profiles") .from("profiles")
.update(payload) .update(payload)

View File

@@ -276,6 +276,131 @@ export const MOCK_PROFILES: Profile[] = [
{ url: "https://images.unsplash.com/photo-1504257432389-52343af06da3?w=400&h=500&fit=crop", is_explicit: false }, { 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 }, { url: "https://placehold.co/400x500/1a1220/ff2d6b?text=Explicit", is_explicit: true },
], ],
community: "gay-men",
}),
withDistance({
id: "8",
username: "ftl_lesbian",
display_name: "Sam",
age: 27,
position: "vers",
bio: "WilMa lesbian scene. Coffee then more.",
avatar_url: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=200&h=200&fit=crop",
status: "looking",
is_anonymous: false,
is_verified: true,
kinks: ["oral", "underwear", "exhibitionist"],
sti_status: "Negative",
on_prep: false,
last_active: new Date(Date.now() - 75000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.022),
lng: jitter(DEFAULT_LNG, 0.018),
city: "Fort Lauderdale",
state: "FL",
id_verified: true,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
height_in: 66,
weight_lb: 140,
body_type: "fit",
orientation: "lesbian",
community: "lesbian",
headline: "Women only",
gallery: [
{ url: "https://images.unsplash.com/photo-1544005313-94ddf0286df2?w=400&h=500&fit=crop", is_explicit: false },
],
}),
withDistance({
id: "9",
username: "mara_wlm",
display_name: "Mara",
age: 34,
position: "top",
bio: "Hosting gals near the drive. No men.",
avatar_url: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=200&h=200&fit=crop",
status: "hosting",
is_anonymous: false,
is_verified: false,
kinks: ["leather", "roleplay", "group"],
sti_status: null,
on_prep: false,
last_active: new Date().toISOString(),
lat: jitter(DEFAULT_LAT, 0.016),
lng: jitter(DEFAULT_LNG, 0.024),
city: "Fort Lauderdale",
state: "FL",
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
orientation: "lesbian",
community: "lesbian",
hosting: "Can host",
gallery: [
{ url: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=400&h=500&fit=crop", is_explicit: false },
],
}),
withDistance({
id: "10",
username: "trans_vers",
display_name: "Jordan",
age: 30,
position: "vers",
bio: "Trans masc. Into confident guys.",
avatar_url: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200&h=200&fit=crop",
status: "online",
is_anonymous: false,
is_verified: true,
kinks: ["oral", "daddy", "leather"],
sti_status: "Negative, On PrEP",
on_prep: true,
last_active: new Date(Date.now() - 40000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.014),
lng: jitter(DEFAULT_LNG, 0.016),
city: "Fort Lauderdale",
state: "FL",
id_verified: true,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
orientation: "trans",
community: "trans",
seeking: "men",
headline: "Guys 25+",
gallery: [
{ url: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=500&fit=crop", is_explicit: false },
],
}),
withDistance({
id: "11",
username: "nb_wlw",
display_name: "Riley",
age: 25,
position: "side",
bio: "Nonbinary. Mostly connect with women & enbies.",
avatar_url: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200&h=200&fit=crop",
status: "looking",
is_anonymous: false,
is_verified: false,
kinks: ["cruising", "underwear"],
sti_status: null,
on_prep: false,
last_active: new Date(Date.now() - 110000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.02),
lng: jitter(DEFAULT_LNG, 0.02),
city: "Fort Lauderdale",
state: "FL",
id_verified: false,
id_verified_at: null,
vanilla_mode: false,
created_at: new Date().toISOString(),
orientation: "nonbinary",
community: "nonbinary",
seeking: "women",
gallery: [
{ url: "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=400&h=500&fit=crop", is_explicit: false },
],
}), }),
]; ];

View File

@@ -1,5 +1,17 @@
export type Position = "top" | "bottom" | "vers" | "side"; export type Position = "top" | "bottom" | "vers" | "side";
export type AudienceCommunity =
| "gay-men"
| "lesbian"
| "trans"
| "nonbinary"
| "bi"
| "queer";
export type SeekingPreference = "men" | "women" | "everyone";
export type AudienceViewMode = "gay-men" | "lesbian" | "trans" | "bi" | "everyone";
export type UserStatus = "online" | "looking" | "hosting" | "traveling" | "offline"; export type UserStatus = "online" | "looking" | "hosting" | "traveling" | "offline";
export interface ProfilePhoto { export interface ProfilePhoto {
@@ -36,6 +48,10 @@ export interface Profile {
body_type?: string | null; body_type?: string | null;
endowment?: string | null; endowment?: string | null;
orientation?: string | null; orientation?: string | null;
/** Primary community pool (gay-men, lesbian, trans, etc.) */
community?: AudienceCommunity | null;
/** For trans/NB — who they connect with; controls cross-view visibility */
seeking?: SeekingPreference | null;
headline?: string | null; headline?: string | null;
into_public?: string[]; into_public?: string[];
fetishes?: string[]; fetishes?: string[];

View File

@@ -0,0 +1,7 @@
-- Profile community + seeking for audience map filtering
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS orientation TEXT,
ADD COLUMN IF NOT EXISTS community TEXT
CHECK (community IS NULL OR community IN ('gay-men', 'lesbian', 'trans', 'nonbinary', 'bi', 'queer')),
ADD COLUMN IF NOT EXISTS seeking TEXT
CHECK (seeking IS NULL OR seeking IN ('men', 'women', 'everyone'));