Add audience views: gay men, lesbian, trans with smart cross-visibility and entry picker
This commit is contained in:
@@ -331,4 +331,23 @@
|
||||
|
||||
.animate-interest-ticker {
|
||||
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;
|
||||
}
|
||||
@@ -10,7 +10,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ShieldCheck, MapPin } from "lucide-react";
|
||||
import { INTEREST_OPTIONS } from "@/lib/interests";
|
||||
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 { AuthModal } from "@/components/AuthModal";
|
||||
import { MediaGalleryManager } from "@/components/MediaGalleryManager";
|
||||
@@ -18,7 +24,7 @@ import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
||||
import { IdVerificationModal } from "@/components/IdVerificationModal";
|
||||
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
||||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||
import { useAccess, useFeedPreferences } from "@/components/Providers";
|
||||
import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Providers";
|
||||
import {
|
||||
US_STATES,
|
||||
getUserState,
|
||||
@@ -32,6 +38,7 @@ export default function ProfilePage() {
|
||||
const { user, profile, loading, updateProfile, signOut } = useAuth();
|
||||
const { capabilities } = useAccess();
|
||||
const { setFeedInterests } = useFeedPreferences();
|
||||
const { audienceView, setAudienceView } = useAudienceView();
|
||||
const [authOpen, setAuthOpen] = useState(false);
|
||||
const [idModalOpen, setIdModalOpen] = useState(false);
|
||||
const [isAnonymous, setIsAnonymous] = useState(true);
|
||||
@@ -45,6 +52,8 @@ export default function ProfilePage() {
|
||||
const [state, setState] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [community, setCommunity] = useState<AudienceCommunity>("gay-men");
|
||||
const [seeking, setSeeking] = useState<SeekingPreference | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
@@ -59,6 +68,8 @@ export default function ProfilePage() {
|
||||
setOnPrep(profile.on_prep);
|
||||
setStiStatus(profile.sti_status || "");
|
||||
setState(profile.state || getUserState() || "");
|
||||
setCommunity(profile.community ?? resolveProfileCommunity(profile));
|
||||
setSeeking(profile.seeking ?? null);
|
||||
} else {
|
||||
const local = localStorage.getItem("eg_profile");
|
||||
setState(getUserState() || "");
|
||||
@@ -73,6 +84,8 @@ export default function ProfilePage() {
|
||||
setKinks(p.kinks || []);
|
||||
setOnPrep(p.onPrep || false);
|
||||
setStiStatus(p.stiStatus || "");
|
||||
if (p.community) setCommunity(p.community);
|
||||
if (p.seeking) setSeeking(p.seeking);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -104,6 +117,10 @@ export default function ProfilePage() {
|
||||
sti_status: stiStatus || null,
|
||||
is_anonymous: isAnonymous,
|
||||
state: state || null,
|
||||
community,
|
||||
seeking:
|
||||
community === "trans" || community === "nonbinary" ? seeking : null,
|
||||
orientation: community === "gay-men" ? "gay" : community,
|
||||
status: "online" as const,
|
||||
city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale",
|
||||
lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"),
|
||||
@@ -125,6 +142,8 @@ export default function ProfilePage() {
|
||||
onPrep: data.on_prep,
|
||||
stiStatus: data.sti_status,
|
||||
isAnonymous: data.is_anonymous,
|
||||
community: data.community,
|
||||
seeking: data.seeking,
|
||||
})
|
||||
);
|
||||
setMessage("Profile saved locally. Sign up free for cloud sync.");
|
||||
@@ -240,6 +259,67 @@ export default function ProfilePage() {
|
||||
</CardContent>
|
||||
</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'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>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Details</CardTitle>
|
||||
|
||||
@@ -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
|
||||
|
||||
60
src/components/AudiencePicker.tsx
Normal file
60
src/components/AudiencePicker.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
61
src/components/AudienceSwitcher.tsx
Normal file
61
src/components/AudienceSwitcher.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
176
src/lib/audience.ts
Normal file
176
src/lib/audience.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,31 @@ function mapDbProfile(row: Record<string, unknown>): Profile {
|
||||
id_verified_at: (row.id_verified_at as string) ?? null,
|
||||
vanilla_mode: (row.vanilla_mode as boolean) ?? false,
|
||||
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 }) {
|
||||
const supabase = createClient();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@@ -70,7 +92,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
setProfile(mapDbProfile(data));
|
||||
const mapped = mapDbProfile(data);
|
||||
const meta = loadProfileMeta(userId);
|
||||
setProfile({ ...mapped, ...meta });
|
||||
}
|
||||
},
|
||||
[supabase]
|
||||
@@ -145,6 +169,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (data.state !== undefined) payload.state = data.state;
|
||||
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.orientation !== undefined) payload.orientation = data.orientation;
|
||||
|
||||
saveProfileMeta(user.id, {
|
||||
community: data.community,
|
||||
seeking: data.seeking,
|
||||
orientation: data.orientation,
|
||||
});
|
||||
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update(payload)
|
||||
|
||||
@@ -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://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 },
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
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 interface ProfilePhoto {
|
||||
@@ -36,6 +48,10 @@ export interface Profile {
|
||||
body_type?: string | null;
|
||||
endowment?: 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;
|
||||
into_public?: string[];
|
||||
fetishes?: string[];
|
||||
|
||||
7
supabase/migration_v4_audience.sql
Normal file
7
supabase/migration_v4_audience.sql
Normal 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'));
|
||||
Reference in New Issue
Block a user