diff --git a/src/app/globals.css b/src/app/globals.css index 934a50a..de569c1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; } \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 6b45e51..373385f 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -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("gay-men"); + const [seeking, setSeeking] = useState(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() { + + + Community + + +
+ +
+ {COMMUNITY_OPTIONS.map((opt) => ( + { + setCommunity(opt.id); + if (opt.id !== "trans" && opt.id !== "nonbinary") { + setSeeking(null); + } else if (!seeking) { + setSeeking("men"); + } + }} + > + {opt.label} + + ))} +
+
+ + {(community === "trans" || community === "nonbinary") && ( +
+ +

+ Controls whether you appear in the gay-men or lesbian map view. + Trans view always shows you. +

+
+ {SEEKING_OPTIONS.map((opt) => ( + setSeeking(opt.id)} + > + {opt.label} + + ))} +
+
+ )} + +
+ + +
+
+
+ Details diff --git a/src/components/AgeGate.tsx b/src/components/AgeGate.tsx index 1b63473..439fffb 100644 --- a/src/components/AgeGate.tsx +++ b/src/components/AgeGate.tsx @@ -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("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) {

- ExposedGays + {formatSiteTitle(audienceView)}

Adults only. Explicit content ahead. @@ -149,6 +153,15 @@ export function AgeGate({ onVerified }: AgeGateProps) { )} +

+ + +
+

No account needed. Verify your age diff --git a/src/components/AudiencePicker.tsx b/src/components/AudiencePicker.tsx new file mode 100644 index 0000000..32ca7b6 --- /dev/null +++ b/src/components/AudiencePicker.tsx @@ -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 ( +

+ {!compact && ( +

+ 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. +

+ )} +
+ {AUDIENCE_VIEW_OPTIONS.map((opt) => { + const active = value === opt.id; + return ( + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/AudienceSwitcher.tsx b/src/components/AudienceSwitcher.tsx new file mode 100644 index 0000000..2cbba70 --- /dev/null +++ b/src/components/AudienceSwitcher.tsx @@ -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 ( + <> + + + + + + Who do you want to see? + + Your map, previews, and nearby cruisers update instantly. + + + { + setAudienceView(mode); + setOpen(false); + }} + /> + + + + ); +} \ No newline at end of file diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 13937b9..c3402fb 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -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(MOCK_PROFILES); const [spots] = useState(MOCK_SPOTS); const [selectedProfile, setSelectedProfile] = useState(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); diff --git a/src/components/MobileHeader.tsx b/src/components/MobileHeader.tsx index 73cf048..646e53c 100644 --- a/src/components/MobileHeader.tsx +++ b/src/components/MobileHeader.tsx @@ -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 (
- ExposedGays + {siteTitle} {activeCount > 0 && ( @@ -34,6 +38,7 @@ export function MobileHeader({
+