Add audience views: gay men, lesbian, trans with smart cross-visibility and entry picker
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user