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

@@ -5,6 +5,8 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/Logo";
import { US_STATES, setUserState, requiresIdVerification } from "@/lib/id-verification";
import { AudiencePicker } from "@/components/AudiencePicker";
import { saveAudienceView, formatSiteTitle, type AudienceViewMode } from "@/lib/audience";
interface AgeGateProps {
onVerified: () => void;
@@ -17,6 +19,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
const [state, setState] = useState("");
const [error, setError] = useState("");
const [agreed, setAgreed] = useState(false);
const [audienceView, setAudienceView] = useState<AudienceViewMode>("gay-men");
const handleVerify = () => {
const m = parseInt(month, 10);
@@ -51,6 +54,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
return;
}
saveAudienceView(audienceView);
localStorage.setItem("eg_age_verified", "true");
localStorage.setItem("eg_age_verified_at", new Date().toISOString());
setUserState(state);
@@ -74,7 +78,7 @@ export function AgeGate({ onVerified }: AgeGateProps) {
<Logo size="lg" showText={false} />
</div>
<h1 className="text-2xl font-bold gradient-text">
ExposedGays
{formatSiteTitle(audienceView)}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Adults only. Explicit content ahead.
@@ -149,6 +153,15 @@ export function AgeGate({ onVerified }: AgeGateProps) {
)}
</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">
<p className="text-xs text-foreground/90 leading-relaxed">
<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 { InterestTicker } from "@/components/InterestTicker";
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 { useOpenIdVerification, useAccess } from "@/components/Providers";
import { distanceMiles } from "@/lib/profile-stats";
@@ -50,6 +51,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
const openIdVerification = useOpenIdVerification();
const { capabilities, blurExplicit } = useAccess();
const { feedInterests } = useFeedPreferences();
const { audienceView } = useAudienceView();
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
@@ -148,20 +150,22 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
return false;
if (feedInterests.length && !profileMatchesInterests(p.kinks, feedInterests))
return false;
if (!profileMatchesAudienceView(p, audienceView)) return false;
if (filters.onPrep === true && !p.on_prep) return false;
if (filters.status.length && !filters.status.includes(p.status)) return false;
return true;
});
}, [profiles, filters, feedInterests]);
}, [profiles, filters, feedInterests, audienceView]);
const interestMatches = useMemo(() => {
if (!feedInterests.length) return [];
return profiles.filter(
(p) =>
p.status !== "offline" &&
profileMatchesAudienceView(p, audienceView) &&
profileMatchesInterests(p.kinks, feedInterests)
);
}, [profiles, feedInterests]);
}, [profiles, feedInterests, audienceView]);
useEffect(() => {
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 { Logo } from "@/components/Logo";
import { Eye, EyeOff } from "lucide-react";
import { AudienceSwitcher } from "@/components/AudienceSwitcher";
import { useAudienceView } from "@/components/Providers";
interface MobileHeaderProps {
vanillaMode: boolean;
@@ -19,12 +21,14 @@ export function MobileHeader({
activeCount = 0,
isGuest,
}: MobileHeaderProps) {
const { siteTitle } = useAudienceView();
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">
<Link href="/" className="flex items-center gap-1.5 min-w-0">
<Logo size="sm" showText={false} />
<span className="text-sm font-bold gradient-text truncate">
ExposedGays
{siteTitle}
</span>
{activeCount > 0 && (
<Badge variant="online" className="text-[9px] shrink-0">
@@ -34,6 +38,7 @@ export function MobileHeader({
</Link>
<div className="flex items-center gap-1">
<AudienceSwitcher />
<Button
variant="ghost"
size="icon"

View File

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

View File

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