Go-live: live data wiring, auth callback, SEO, legal pages, UI fixes

This commit is contained in:
Ryan Salazar
2026-06-27 14:08:31 -04:00
parent d533cdc194
commit 910a7d8516
34 changed files with 2780 additions and 61 deletions

View File

@@ -30,6 +30,9 @@ Open [http://localhost:3000](http://localhost:3000)
| Vanilla mode blur toggle | ✅ | | Vanilla mode blur toggle | ✅ |
| Dark mode, mobile-first, PWA manifest | ✅ | | Dark mode, mobile-first, PWA manifest | ✅ |
| Anonymous browsing + optional profile | ✅ | | Anonymous browsing + optional profile | ✅ |
| **Hosting events** (Sniffies-style map parties) | ✅ |
| Schedule ahead · 2 photos + 1 video · invites | ✅ |
| Request/auto-accept · edit · cancel · RSVP | ✅ |
## Tech Stack ## Tech Stack

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Apply ExposedGays Supabase functions + seed if missing."""
import paramiko
HOST = "10.10.0.11"
USER = "localadministrator"
PASS = "Bbt9115xty9176!"
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False)
def run(cmd, t=120):
print("$", cmd[:140])
_, o, e = c.exec_command(cmd, timeout=t)
code = o.channel.recv_exit_status()
out = (o.read() + e.read()).decode(errors="replace")
print(out[-3000:])
print("exit", code, "\n")
return code
# Pipe functions.sql into postgres
with open(r"C:\Users\Local Administrator\exposedgays\supabase\functions.sql", "r", encoding="utf-8") as f:
sql = f.read()
sftp = c.open_sftp()
with sftp.file("/tmp/eg_functions.sql", "w") as remote:
remote.write(sql)
sftp.close()
run("cat /tmp/eg_functions.sql | docker exec -i supabase-db psql -U postgres -d postgres 2>&1 | tail -20")
# Ensure privacy columns exist
run(
"docker exec -i supabase-db psql -U postgres -d postgres -c "
"\"ALTER TABLE profiles ADD COLUMN IF NOT EXISTS height_in integer, "
"ADD COLUMN IF NOT EXISTS weight_lb integer, "
"ADD COLUMN IF NOT EXISTS body_type text, "
"ADD COLUMN IF NOT EXISTS community text, "
"ADD COLUMN IF NOT EXISTS seeking text;\" 2>&1"
)
c.close()
print("Done.")

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Retry stalled Pink Pulse + Supabase tasks with tight timeouts."""
import paramiko
import sys
COOLIFY = ("10.10.0.10", "localadministrator", "Bbt9115xty9176!")
SUPABASE = ("10.10.0.11", "localadministrator", "Bbt9115xty9176!")
MIGRATION_LOCAL = r"C:\Users\Local Administrator\exposedgays\supabase\migration_pulse_analytics.sql"
def run(host, user, password, cmd, timeout=45):
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(host, username=user, password=password, timeout=15, allow_agent=False, look_for_keys=False)
try:
print(f"\n=== {host}: {cmd[:120]} ===")
_, o, e = c.exec_command(cmd, timeout=timeout)
code = o.channel.recv_exit_status()
out = (o.read() + e.read()).decode(errors="replace")
print(out[:8000] if out else "(no output)")
print(f"exit {code}")
return code, out
finally:
c.close()
def scp_file(host, user, password, local_path, remote_path, timeout=60):
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(host, username=user, password=password, timeout=15, allow_agent=False, look_for_keys=False)
try:
print(f"\n=== SCP {local_path} -> {host}:{remote_path} ===")
sftp = c.open_sftp()
sftp.put(local_path, remote_path)
sftp.close()
print("SCP ok")
return 0
finally:
c.close()
def main():
h, u, p = COOLIFY
errors = 0
# 1. Pink Pulse ads dir
code, _ = run(h, u, p,
"test -d /home/localadministrator/thepinkpulse && "
"ls /home/localadministrator/thepinkpulse/src/components/ads/ 2>/dev/null | head -10 || "
"echo 'ads dir missing or empty'"
)
if code != 0:
errors += 1
# 2. Pink Pulse advertising grep
code, _ = run(h, u, p,
"grep -rn 'advertis\\|Advertise\\|popup' "
"/home/localadministrator/thepinkpulse/src/components --include='*.tsx' 2>/dev/null | head -40 || "
"echo 'no matches'"
)
if code != 0:
errors += 1
# 3. ExposedGays health
code, _ = run(h, u, p, "curl -sS --max-time 10 https://exposedgays.com/ 2>&1 | head -c 300")
if code != 0:
errors += 1
# 4. Supabase migration
sh, su, sp = SUPABASE
try:
scp_file(sh, su, sp, MIGRATION_LOCAL, "/tmp/migration_pulse_analytics.sql")
except Exception as ex:
print(f"SCP failed: {ex}")
errors += 1
else:
code, out = run(
sh, su, sp,
"cat /tmp/migration_pulse_analytics.sql | "
"docker exec -i supabase-db psql -U postgres -d postgres 2>&1 | tail -40",
timeout=90,
)
if code != 0 and "already exists" not in out.lower():
errors += 1
print(f"\n=== DONE ({errors} issues) ===")
sys.exit(1 if errors else 0)
if __name__ == "__main__":
main()

View File

@@ -4,6 +4,22 @@ import type { PulseTrackPayload } from "@/lib/pulse-analytics";
export const runtime = "nodejs"; export const runtime = "nodejs";
const rateBucket = new Map<string, { count: number; reset: number }>();
const RATE_LIMIT = 60;
const RATE_WINDOW_MS = 60_000;
function checkRateLimit(key: string): boolean {
const now = Date.now();
const entry = rateBucket.get(key);
if (!entry || now > entry.reset) {
rateBucket.set(key, { count: 1, reset: now + RATE_WINDOW_MS });
return true;
}
if (entry.count >= RATE_LIMIT) return false;
entry.count += 1;
return true;
}
function adminClient() { function adminClient() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL; const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const key = process.env.SUPABASE_SERVICE_ROLE_KEY; const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
@@ -24,6 +40,15 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: "missing_fields" }, { status: 400 }); return NextResponse.json({ ok: false, error: "missing_fields" }, { status: 400 });
} }
const ip =
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
req.headers.get("x-real-ip") ||
"unknown";
const rateKey = `${ip}:${payload.session_id ?? "anon"}`;
if (!checkRateLimit(rateKey)) {
return NextResponse.json({ ok: false, error: "rate_limited" }, { status: 429 });
}
const supabase = adminClient(); const supabase = adminClient();
if (!supabase) { if (!supabase) {
return NextResponse.json({ ok: true, stored: false }); return NextResponse.json({ ok: true, stored: false });

View File

@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/profile';
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return NextResponse.redirect(`${origin}${next}`);
}
}
return NextResponse.redirect(`${origin}/profile?auth=error`);
}

View File

@@ -7,10 +7,12 @@ import { ChatPanel } from "@/components/ChatPanel";
function ChatContent() { function ChatContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const userId = searchParams.get("user"); const userId = searchParams.get("user");
const spotId = searchParams.get("spot");
const spotRoom = searchParams.get("room");
return ( return (
<div className="h-[calc(100dvh-7rem)] md:h-[calc(100dvh-3.5rem)]"> <div className="h-[calc(100dvh-7rem)] md:h-[calc(100dvh-3.5rem)]">
<ChatPanel dmUserId={userId} /> <ChatPanel dmUserId={userId} spotId={spotId} spotRoomName={spotRoom} />
</div> </div>
); );
} }

82
src/app/contact/page.tsx Normal file
View File

@@ -0,0 +1,82 @@
import Link from 'next/link';
import { Mail, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export const metadata = {
title: 'Contact & Safety — ExposedGays',
description: 'Report abuse, contact support, and safety resources for ExposedGays.',
};
export default function ContactPage() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold">Contact &amp; Safety</h1>
<p className="text-sm text-muted-foreground mt-2">
ExposedGays is operated by Just Two Roommates LLC. We take user safety seriously.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Mail className="h-5 w-5 text-primary" />
Support
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
General support:{' '}
<a href="mailto:support@justtworoommates.com" className="text-primary hover:underline">
support@justtworoommates.com
</a>
</p>
<p>
Advertising inquiries:{' '}
<a
href="mailto:advertising@justtworoommates.com"
className="text-primary hover:underline"
>
advertising@justtworoommates.com
</a>
</p>
</CardContent>
</Card>
<Card className="border-amber-500/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5 text-amber-400" />
Report abuse or illegal content
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
To report harassment, impersonation, underage users, or illegal content, email{' '}
<a href="mailto:abuse@justtworoommates.com" className="text-amber-400 hover:underline">
abuse@justtworoommates.com
</a>{' '}
with screenshots, usernames, and timestamps. We review reports within 24 hours.
</p>
<p>
You can also block users instantly from their profile menu and manage blocks in{' '}
<Link href="/profile" className="text-primary hover:underline">
Profile Privacy
</Link>
.
</p>
</CardContent>
</Card>
<div className="flex gap-3">
<Button variant="horny" asChild>
<Link href="/">Back to Map</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/terms">Terms</Link>
</Button>
</div>
</div>
);
}

View File

@@ -209,6 +209,63 @@
padding: 0 3px; padding: 0 3px;
} }
.sniffies-event-wrap {
background: transparent !important;
border: none !important;
}
.sniffies-event {
position: relative;
width: 48px;
height: 52px;
display: flex;
flex-direction: column;
align-items: center;
}
.sniffies-event-pin {
width: 40px;
height: 40px;
border-radius: 12px;
background: linear-gradient(145deg, #ff2d9b 0%, #8b22cc 100%);
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 0 0 2px rgba(249, 43, 155, 0.4), 0 4px 14px rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
line-height: 1;
}
.sniffies-event-pin.sniffies-event-live {
animation: sniffies-event-pulse 1.8s ease-in-out infinite;
}
@keyframes sniffies-event-pulse {
0%,
100% {
box-shadow: 0 0 0 2px rgba(249, 43, 155, 0.4), 0 4px 14px rgba(0, 0, 0, 0.55);
}
50% {
box-shadow: 0 0 0 6px rgba(249, 43, 155, 0.25), 0 4px 18px rgba(249, 43, 155, 0.35);
}
}
.sniffies-event-count {
margin-top: 2px;
min-width: 18px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #1a003a;
border: 1px solid rgba(249, 43, 155, 0.5);
color: #ff6ec7;
font-size: 9px;
font-weight: 700;
line-height: 14px;
text-align: center;
}
.leaflet-popup-content-wrapper { .leaflet-popup-content-wrapper {
background: hsl(268 100% 11%); background: hsl(268 100% 11%);
color: hsl(228 100% 97%); color: hsl(228 100% 97%);

View File

@@ -8,11 +8,31 @@ const manrope = Manrope({
variable: "--font-manrope", variable: "--font-manrope",
}); });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://exposedgays.com";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "ExposedGays — Free Gay Cruising & Shop", metadataBase: new URL(siteUrl),
title: {
default: "ExposedGays — Free Gay Cruising & Shop",
template: "%s · ExposedGays",
},
description: description:
"100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.", "100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.",
manifest: "/manifest.json", manifest: "/manifest.json",
openGraph: {
type: "website",
locale: "en_US",
url: siteUrl,
siteName: "ExposedGays",
title: "ExposedGays — Free Gay Cruising & Shop",
description:
"100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.",
},
twitter: {
card: "summary_large_image",
title: "ExposedGays — Free Gay Cruising",
description: "Free gay cruising map, chat & spots. No subscriptions ever.",
},
icons: { icons: {
icon: "/logo.svg", icon: "/logo.svg",
apple: "/logo.svg", apple: "/logo.svg",

21
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,21 @@
import Link from 'next/link';
import { Button } from '@/components/ui/button';
export default function NotFound() {
return (
<div className="mx-auto max-w-md px-4 py-20 text-center space-y-6">
<h1 className="text-4xl font-bold gradient-text">404</h1>
<p className="text-muted-foreground">
This page doesn&apos;t exist but the map is still hot.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button variant="horny" asChild>
<Link href="/">Back to Map</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/spots">Cruising Spots</Link>
</Button>
</div>
</div>
);
}

View File

@@ -1,18 +1,16 @@
"use client"; import { Suspense } from "react";
import HomeMap from "@/components/HomeMap";
import { CruisingMap } from "@/components/Map";
import { useSetActiveCount, useVanillaMode } from "@/components/Providers";
export default function HomePage() { export default function HomePage() {
const setActiveCount = useSetActiveCount();
const vanillaMode = useVanillaMode();
return ( return (
<div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] -mx-0 flex-1"> <Suspense
<CruisingMap fallback={
vanillaMode={vanillaMode} <div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] flex items-center justify-center text-muted-foreground text-sm">
onActiveCountChange={setActiveCount} Loading map
/>
</div> </div>
}
>
<HomeMap />
</Suspense>
); );
} }

View File

@@ -1,13 +1,112 @@
import Link from 'next/link';
export const metadata = {
title: 'Privacy Policy — ExposedGays',
};
export default function PrivacyPage() { export default function PrivacyPage() {
return ( return (
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert"> <div className="mx-auto max-w-2xl px-4 py-8 space-y-6 text-sm text-muted-foreground leading-relaxed">
<h1>Privacy Policy</h1> <h1 className="text-2xl font-bold text-foreground">Privacy Policy</h1>
<p className="text-muted-foreground text-sm"> <p className="text-xs text-muted-foreground">Last updated: June 27, 2026</p>
ExposedGays is operated by Just Two Roommates LLC. We collect minimal data.
Location is used for map features and city chat auto-join. Anonymous browsing <section className="space-y-2">
is supported. Cart data stays in your browser (localStorage). When you follow <h2 className="text-lg font-semibold text-foreground">Who we are</h2>
links to our community partner The Pink Pulse, their privacy policy applies on <p>
their site. Placeholder policy consult legal counsel before launch. ExposedGays is operated by <strong>Just Two Roommates LLC</strong>, Wilton Manors, FL.
Contact:{' '}
<a href="mailto:support@justtworoommates.com" className="text-primary hover:underline">
support@justtworoommates.com
</a>
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">What we collect</h2>
<ul className="list-disc pl-5 space-y-1">
<li>
<strong>Account:</strong> email (if you sign up), profile fields you choose to share
(age, photos, bio, interests, health info you enter).
</li>
<li>
<strong>Location:</strong> approximate map coordinates for cruising features and city
chat auto-join. You can browse with limited location precision.
</li>
<li>
<strong>Device:</strong> browser type, session IDs for analytics, age-gate state in
localStorage.
</li>
<li>
<strong>Messages:</strong> chat content stored when you use city, spot, or DM features
while signed in.
</li>
</ul>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">What stays on your device</h2>
<p>
Guest browsing stores profile drafts, block lists, hide rules, and cart items in{' '}
<strong>localStorage</strong> until you create an account. Signed-in users sync profile
and gallery to our database (Supabase, self-hosted).
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">Photos &amp; X-rated scan</h2>
<p>
Uploaded images are automatically scanned for explicit content before display to other
users. Explicit media is blurred for guests and users in states requiring ID verification
until verification is complete.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">Privacy controls you have</h2>
<ul className="list-disc pl-5 space-y-1">
<li>Block list mutual invisibility</li>
<li>Hide-from-trait rules control who can see you</li>
<li>Anonymous browsing mode</li>
<li>Separate toggles for anonymous and no-photo users</li>
</ul>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">Third parties</h2>
<p>
We use self-hosted Supabase for database/auth, Cloudflare for delivery, and may link to{' '}
<strong>The Pink Pulse</strong> for news and events. Partner sites have their own privacy
policies. Shop affiliate links go to external retailers.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">Retention &amp; deletion</h2>
<p>
Delete your account from Profile Sign Out and email{' '}
<a href="mailto:support@justtworoommates.com" className="text-primary hover:underline">
support@justtworoommates.com
</a>{' '}
for full data removal. We retain abuse reports as needed for safety investigations.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">Your rights</h2>
<p>
California and EU users may request access, correction, or deletion of personal data.
Contact us with your account email.
</p>
</section>
<p>
<Link href="/contact" className="text-primary hover:underline">
Contact &amp; Safety
</Link>{' '}
·{' '}
<Link href="/terms" className="text-primary hover:underline">
Terms of Service
</Link>
</p> </p>
</div> </div>
); );

13
src/app/robots.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const base = process.env.NEXT_PUBLIC_SITE_URL || 'https://exposedgays.com';
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/profile'],
},
sitemap: `${base}/sitemap.xml`,
};
}

16
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const base = process.env.NEXT_PUBLIC_SITE_URL || 'https://exposedgays.com';
const now = new Date();
return [
{ url: base, lastModified: now, changeFrequency: 'hourly', priority: 1 },
{ url: `${base}/spots`, lastModified: now, changeFrequency: 'daily', priority: 0.9 },
{ url: `${base}/chat`, lastModified: now, changeFrequency: 'hourly', priority: 0.9 },
{ url: `${base}/shop`, lastModified: now, changeFrequency: 'weekly', priority: 0.7 },
{ url: `${base}/profile`, lastModified: now, changeFrequency: 'weekly', priority: 0.6 },
{ url: `${base}/terms`, lastModified: now, changeFrequency: 'monthly', priority: 0.3 },
{ url: `${base}/privacy`, lastModified: now, changeFrequency: 'monthly', priority: 0.3 },
{ url: `${base}/contact`, lastModified: now, changeFrequency: 'monthly', priority: 0.4 },
];
}

View File

@@ -1,15 +1,107 @@
import Link from 'next/link';
export const metadata = {
title: 'Terms of Service — ExposedGays',
};
export default function TermsPage() { export default function TermsPage() {
return ( return (
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert"> <div className="mx-auto max-w-2xl px-4 py-8 space-y-6 text-sm text-muted-foreground leading-relaxed">
<h1>Terms of Service</h1> <h1 className="text-2xl font-bold text-foreground">Terms of Service</h1>
<p className="text-muted-foreground text-sm"> <p className="text-xs text-muted-foreground">Last updated: June 27, 2026</p>
ExposedGays is a free adult platform operated by Just Two Roommates LLC. You must
be 18+ to use this site. All content is user-generated. We reserve the right to <section className="space-y-2">
remove illegal content. No paid subscriptions ever. Links to our community <h2 className="text-lg font-semibold text-foreground">1. Operator</h2>
partner The Pink Pulse (thepinkpulse.com) are provided for LGBTQ+ news, events, <p>
and local resources separate editorial property, same parent company. ExposedGays.com is operated by <strong>Just Two Roommates LLC</strong> (&quot;we,&quot;
Placeholder terms consult legal counsel before launch. &quot;us&quot;). This is an adult-only platform for gay and queer men and aligned
communities. By using the site you agree to these terms.
</p> </p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">2. Eligibility</h2>
<p>
You must be <strong>18 years or older</strong> to use ExposedGays. You confirm you are of
legal age in your jurisdiction. We may terminate accounts that appear to belong to minors
without notice.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">3. Free service</h2>
<p>
Core features map, chat, spots directory, and profile are <strong>free forever</strong>.
We do not sell subscriptions for cruising features. The shop section contains affiliate
links to third-party retailers; those purchases are governed by the retailer&apos;s terms.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">4. User content &amp; conduct</h2>
<ul className="list-disc pl-5 space-y-1">
<li>You are responsible for photos, messages, and profile information you post.</li>
<li>No illegal content, harassment, impersonation, or non-consensual imagery.</li>
<li>No solicitation of minors or trafficking-related activity zero tolerance.</li>
<li>We may remove content and ban users at our discretion.</li>
</ul>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">5. Privacy &amp; location</h2>
<p>
Location data powers map and city chat features. You control visibility through profile
privacy settings, block lists, and hide rules. See our{' '}
<Link href="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">6. ID verification</h2>
<p>
In certain US states, explicit media requires government ID verification. Verification
status is stored on your account and may be checked by third-party providers we integrate
with.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">7. Partner properties</h2>
<p>
Links to <strong>The Pink Pulse</strong> (thepinkpulse.com) and other Just Two Roommates
properties are provided for LGBTQ+ news, events, and resources. Those sites have separate
editorial teams and policies.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">8. Disclaimer</h2>
<p>
ExposedGays is provided &quot;as is.&quot; Meet other users at your own risk. We do not
conduct criminal background checks. Report abuse to{' '}
<a href="mailto:abuse@justtworoommates.com" className="text-primary hover:underline">
abuse@justtworoommates.com
</a>
.
</p>
</section>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">9. Contact</h2>
<p>
Questions:{' '}
<Link href="/contact" className="text-primary hover:underline">
Contact page
</Link>{' '}
or{' '}
<a href="mailto:support@justtworoommates.com" className="text-primary hover:underline">
support@justtworoommates.com
</a>
</p>
</section>
</div> </div>
); );
} }

View File

@@ -75,16 +75,20 @@ const MOCK_DM_MESSAGES: Message[] = [
interface ChatPanelProps { interface ChatPanelProps {
dmUserId?: string | null; dmUserId?: string | null;
spotId?: string | null;
spotRoomName?: string | null;
} }
export function ChatPanel({ dmUserId }: ChatPanelProps) { export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
const { capabilities } = useAccess(); const { capabilities } = useAccess();
const { user, profile: authProfile } = useAuth(); const { user, profile: authProfile } = useAuth();
const { blockList } = usePrivacy(); const { blockList } = usePrivacy();
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES); const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES); const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city"); const [activeTab, setActiveTab] = useState(
dmUserId ? "dm" : spotId ? "city" : "city"
);
const [showQuickReplies, setShowQuickReplies] = useState(false); const [showQuickReplies, setShowQuickReplies] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
@@ -161,7 +165,9 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
{cityMessages.length + 12} in room {cityMessages.length + 12} in room
</Badge> </Badge>
<p className="text-xs text-muted-foreground mt-1"> <p className="text-xs text-muted-foreground mt-1">
Auto-joined based on your location. Public city chat. {spotId && spotRoomName
? `Spot chat — ${decodeURIComponent(spotRoomName)}. Also connected to ${CITY} city room.`
: "Auto-joined based on your location. Public city chat."}
</p> </p>
</div> </div>
<MessageList messages={messages} /> <MessageList messages={messages} />

View File

@@ -0,0 +1,343 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { Calendar, ImagePlus, Video, X } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { HOSTING_EVENT_TYPES, EVENT_TYPE_META } from "@/lib/hosting-events";
import {
createHostingEvent,
updateHostingEvent,
type EventMediaDraft,
} from "@/lib/hosting-events-store";
import {
MAX_EVENT_PHOTOS,
MAX_EVENT_VIDEOS,
MAX_EVENT_DESCRIPTION_LENGTH,
} from "@/lib/limits";
import type { HostingEvent, HostingEventType } from "@/types";
import { cn } from "@/lib/utils";
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
interface CreateHostingEventModalProps {
open: boolean;
onClose: () => void;
onSaved: (event: HostingEvent) => void;
hostId: string;
hostMeta?: { display_name?: string | null; avatar_url?: string | null };
editEvent?: HostingEvent | null;
}
function toLocalDatetimeValue(iso: string) {
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromLocalDatetimeValue(value: string) {
return new Date(value).toISOString();
}
export function CreateHostingEventModal({
open,
onClose,
onSaved,
hostId,
hostMeta,
editEvent,
}: CreateHostingEventModalProps) {
const [eventType, setEventType] = useState<HostingEventType>("cum-dump");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [startsAt, setStartsAt] = useState("");
const [endsAt, setEndsAt] = useState("");
const [autoAccept, setAutoAccept] = useState(false);
const [maxGuests, setMaxGuests] = useState("");
const [photos, setPhotos] = useState<EventMediaDraft[]>([]);
const [video, setVideo] = useState<EventMediaDraft | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
if (editEvent) {
setEventType(editEvent.event_type);
setTitle(editEvent.title);
setDescription(editEvent.description);
setStartsAt(toLocalDatetimeValue(editEvent.starts_at));
setEndsAt(toLocalDatetimeValue(editEvent.ends_at));
setAutoAccept(editEvent.auto_accept);
setMaxGuests(editEvent.max_guests ? String(editEvent.max_guests) : "");
setPhotos(
editEvent.media
.filter((m) => m.media_type === "photo")
.map((m) => ({ type: "photo" as const, url: m.url }))
);
const v = editEvent.media.find((m) => m.media_type === "video");
setVideo(v ? { type: "video", url: v.url, thumbnail_url: v.thumbnail_url } : null);
} else {
const start = new Date(Date.now() + 3600000);
const end = new Date(Date.now() + 5 * 3600000);
setEventType("cum-dump");
setTitle(EVENT_TYPE_META["cum-dump"].defaultTitle);
setDescription("");
setStartsAt(toLocalDatetimeValue(start.toISOString()));
setEndsAt(toLocalDatetimeValue(end.toISOString()));
setAutoAccept(false);
setMaxGuests("");
setPhotos([]);
setVideo(null);
}
setError(null);
}, [open, editEvent]);
const handleTypeChange = (type: HostingEventType) => {
setEventType(type);
if (!editEvent && (!title || HOSTING_EVENT_TYPES.some((t) => t.defaultTitle === title))) {
setTitle(EVENT_TYPE_META[type].defaultTitle);
}
};
const readFile = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
const handlePhotoPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
const room = MAX_EVENT_PHOTOS - photos.length;
const picked = files.slice(0, room);
const urls = await Promise.all(picked.map(readFile));
setPhotos((prev) => [
...prev,
...urls.map((url) => ({ type: "photo" as const, url })),
]);
e.target.value = "";
};
const handleVideoPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const url = await readFile(file);
setVideo({ type: "video", url });
e.target.value = "";
};
const handleSave = async () => {
setSaving(true);
setError(null);
const mediaDrafts: EventMediaDraft[] = [
...photos,
...(video ? [video] : []),
];
const input = {
title,
description,
event_type: eventType,
starts_at: fromLocalDatetimeValue(startsAt),
ends_at: fromLocalDatetimeValue(endsAt),
lat: editEvent?.lat ?? DEFAULT_LAT,
lng: editEvent?.lng ?? DEFAULT_LNG,
city: editEvent?.city ?? "Fort Lauderdale",
auto_accept: autoAccept,
max_guests: maxGuests ? parseInt(maxGuests, 10) : null,
};
const result = editEvent
? await updateHostingEvent(editEvent.id, hostId, input, mediaDrafts)
: await createHostingEvent(input, hostId, hostMeta, mediaDrafts);
setSaving(false);
if (result.error) {
setError(result.error);
return;
}
if (result.event) {
onSaved(result.event);
onClose();
}
};
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto bg-pulse-midnight border-primary/20 text-white">
<DialogHeader>
<DialogTitle className="text-white flex items-center gap-2">
<Calendar className="h-5 w-5 text-pulse-pink-light" />
{editEvent ? "Edit Hosting Event" : "Host on the Map"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label className="text-xs text-white/70 mb-2 block">Event type</Label>
<div className="flex flex-wrap gap-1.5">
{HOSTING_EVENT_TYPES.map((t) => (
<Badge
key={t.id}
variant={eventType === t.id ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => handleTypeChange(t.id)}
>
{t.emoji} {t.label}
</Badge>
))}
</div>
</div>
<div>
<Label htmlFor="evt-title" className="text-xs text-white/70">
Title
</Label>
<Input
id="evt-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 bg-black/40 border-white/15"
/>
</div>
<div>
<Label htmlFor="evt-desc" className="text-xs text-white/70">
Description ({description.length}/{MAX_EVENT_DESCRIPTION_LENGTH})
</Label>
<textarea
id="evt-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
maxLength={MAX_EVENT_DESCRIPTION_LENGTH}
className="mt-1 w-full rounded-md border border-white/15 bg-black/40 px-3 py-2 text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-2 focus:ring-primary"
placeholder="Who's welcome, house rules, door code, etc."
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="evt-start" className="text-xs text-white/70">
Starts
</Label>
<Input
id="evt-start"
type="datetime-local"
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
className="mt-1 bg-black/40 border-white/15 text-sm"
/>
</div>
<div>
<Label htmlFor="evt-end" className="text-xs text-white/70">
Ends
</Label>
<Input
id="evt-end"
type="datetime-local"
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
className="mt-1 bg-black/40 border-white/15 text-sm"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border border-white/10 px-3 py-2">
<div>
<p className="text-sm font-medium">Auto-accept requests</p>
<p className="text-xs text-white/60">Anyone can join without approval</p>
</div>
<Switch checked={autoAccept} onCheckedChange={setAutoAccept} />
</div>
<div>
<Label htmlFor="evt-max" className="text-xs text-white/70">
Max guests (optional)
</Label>
<Input
id="evt-max"
type="number"
min={1}
max={500}
value={maxGuests}
onChange={(e) => setMaxGuests(e.target.value)}
placeholder="Unlimited"
className="mt-1 bg-black/40 border-white/15"
/>
</div>
<div>
<Label className="text-xs text-white/70 mb-2 block">
Photos ({photos.length}/{MAX_EVENT_PHOTOS}) · Video ({video ? 1 : 0}/{MAX_EVENT_VIDEOS})
</Label>
<div className="flex flex-wrap gap-2">
{photos.map((p, i) => (
<div key={i} className="relative h-16 w-16 rounded-lg overflow-hidden border border-white/15">
<Image src={p.url} alt="" fill className="object-cover" unoptimized />
<button
type="button"
className="absolute top-0 right-0 p-0.5 bg-black/70 rounded-bl"
onClick={() => setPhotos((prev) => prev.filter((_, j) => j !== i))}
>
<X className="h-3 w-3" />
</button>
</div>
))}
{photos.length < MAX_EVENT_PHOTOS && (
<label className="h-16 w-16 rounded-lg border border-dashed border-white/25 flex items-center justify-center cursor-pointer hover:border-primary/50">
<ImagePlus className="h-5 w-5 text-white/50" />
<input type="file" accept="image/*" className="hidden" onChange={handlePhotoPick} />
</label>
)}
{video ? (
<div className="relative h-16 w-16 rounded-lg overflow-hidden border border-primary/40 bg-black flex items-center justify-center">
<Video className="h-6 w-6 text-pulse-pink-light" />
<button
type="button"
className="absolute top-0 right-0 p-0.5 bg-black/70 rounded-bl"
onClick={() => setVideo(null)}
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<label className="h-16 w-16 rounded-lg border border-dashed border-white/25 flex items-center justify-center cursor-pointer hover:border-primary/50">
<Video className="h-5 w-5 text-white/50" />
<input type="file" accept="video/*" className="hidden" onChange={handleVideoPick} />
</label>
)}
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2 pt-2">
<Button variant="ghost" className="flex-1" onClick={onClose}>
Cancel
</Button>
<Button
className={cn("flex-1 bg-pulse-gradient-bold text-white")}
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving…" : editEvent ? "Save changes" : "Post event"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { CruisingMap } from '@/components/Map';
import { useSetActiveCount, useVanillaMode } from '@/components/Providers';
export default function HomeMap() {
const setActiveCount = useSetActiveCount();
const vanillaMode = useVanillaMode();
const searchParams = useSearchParams();
const spotId = searchParams.get('spot');
return (
<div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] -mx-0 flex-1">
<CruisingMap
vanillaMode={vanillaMode}
onActiveCountChange={setActiveCount}
focusSpotId={spotId}
/>
</div>
);
}

View File

@@ -0,0 +1,364 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import {
ArrowLeft,
Calendar,
Check,
Clock,
MapPin,
Pencil,
UserPlus,
Users,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
acceptedAttendeeCount,
EVENT_TYPE_META,
formatEventCountdown,
pendingRequestCount,
resolveEventStatus,
viewerAttendeeStatus,
} from "@/lib/hosting-events";
import {
acceptInvite,
cancelAttendance,
cancelHostingEvent,
inviteToEvent,
requestToJoinEvent,
respondToAttendee,
} from "@/lib/hosting-events-store";
import { useAuth } from "@/lib/auth/context";
import type { HostingEvent, Profile } from "@/types";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
interface HostingEventSheetProps {
event: HostingEvent | null;
open: boolean;
onClose: () => void;
onEdit: (event: HostingEvent) => void;
onRefresh: () => void;
nearbyProfiles?: Profile[];
}
export function HostingEventSheet({
event,
open,
onClose,
onEdit,
onRefresh,
nearbyProfiles = [],
}: HostingEventSheetProps) {
const { user, profile } = useAuth();
const [activeMedia, setActiveMedia] = useState(0);
const [busy, setBusy] = useState(false);
const [showInvite, setShowInvite] = useState(false);
if (!event || !open) return null;
const status = resolveEventStatus(event);
const typeMeta = EVENT_TYPE_META[event.event_type];
const accepted = acceptedAttendeeCount(event);
const pending = pendingRequestCount(event);
const userId = user?.id ?? null;
const isHost = userId === event.host_id;
const myStatus = viewerAttendeeStatus(event, userId);
const photos = event.media.filter((m) => m.media_type === "photo");
const video = event.media.find((m) => m.media_type === "video");
const heroUrl = photos[activeMedia]?.url ?? photos[0]?.url ?? null;
const handleJoinFixed = async () => {
if (!userId) return;
setBusy(true);
if (myStatus === "invited") {
await acceptInvite(event.id, userId);
} else {
await requestToJoinEvent(event.id, userId, {
display_name: profile?.display_name,
avatar_url: profile?.avatar_url,
});
}
setBusy(false);
onRefresh();
};
const handleCancelAttendance = async () => {
if (!userId) return;
setBusy(true);
await cancelAttendance(event.id, userId);
setBusy(false);
onRefresh();
};
const handleCancelEvent = async () => {
if (!userId || !isHost) return;
setBusy(true);
await cancelHostingEvent(event.id, userId);
setBusy(false);
onClose();
onRefresh();
};
const handleRespond = async (attendeeId: string, accept: boolean) => {
if (!userId) return;
setBusy(true);
await respondToAttendee(event.id, userId, attendeeId, accept);
setBusy(false);
onRefresh();
};
const handleInvite = async (inviteeId: string) => {
if (!userId) return;
setBusy(true);
await inviteToEvent(event.id, userId, inviteeId);
setBusy(false);
onRefresh();
};
const pendingAttendees = event.attendees.filter((a) => a.status === "requested");
const acceptedAttendees = event.attendees.filter((a) => a.status === "accepted");
return (
<div className="fixed inset-0 z-[2000] bg-black/60 flex flex-col justify-end md:items-center md:justify-center md:p-4">
<div
className="w-full max-h-[88vh] md:max-w-lg md:rounded-2xl overflow-hidden flex flex-col bg-pulse-deep-space border-t md:border border-primary/20 shadow-pulse-card"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-white/10 safe-top">
<button type="button" onClick={onClose} className="p-2 text-pulse-pink-light">
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-lg">{typeMeta.emoji}</span>
<h2 className="font-bold text-white truncate">{event.title}</h2>
</div>
<p className="text-xs text-white/60 truncate">
{event.host_display_name ?? "Host"} · {event.city ?? "Nearby"}
</p>
</div>
<Badge
variant={status === "live" ? "online" : "secondary"}
className="shrink-0 text-[10px]"
>
{status === "live" ? "LIVE" : status === "scheduled" ? formatEventCountdown(event.starts_at) : status}
</Badge>
</div>
<div className="flex-1 overflow-y-auto">
{heroUrl && (
<div className="relative aspect-[16/10] bg-black">
<Image src={heroUrl} alt="" fill className="object-cover" unoptimized />
{photos.length > 1 && (
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1">
{photos.map((_, i) => (
<button
key={i}
type="button"
className={cn(
"h-1.5 rounded-full transition-all",
i === activeMedia ? "w-4 bg-white" : "w-1.5 bg-white/40"
)}
onClick={() => setActiveMedia(i)}
/>
))}
</div>
)}
</div>
)}
<div className="p-4 space-y-4">
<div className="flex flex-wrap gap-2 text-xs text-white/70">
<span className="inline-flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{format(new Date(event.starts_at), "EEE MMM d · h:mm a")}
</span>
<span className="inline-flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
until {format(new Date(event.ends_at), "h:mm a")}
</span>
<span className="inline-flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{accepted} going
{event.max_guests ? ` / ${event.max_guests}` : ""}
</span>
<span className="inline-flex items-center gap-1">
<MapPin className="h-3.5 w-3.5" />
On map
</span>
</div>
{event.auto_accept && (
<Badge variant="outline" className="text-[10px]">
Auto-accept on
</Badge>
)}
{event.description && (
<p className="text-sm text-white/90 whitespace-pre-wrap leading-relaxed">
{event.description}
</p>
)}
{event.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{event.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-[10px] uppercase">
{tag}
</Badge>
))}
</div>
)}
{video && (
<video
src={video.url}
controls
className="w-full rounded-lg max-h-48 bg-black"
playsInline
/>
)}
{isHost && pending > 0 && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 space-y-2">
<p className="text-sm font-semibold text-amber-200">
{pending} join request{pending !== 1 ? "s" : ""}
</p>
{pendingAttendees.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-2">
<span className="text-sm text-white truncate">
{a.display_name ?? `User ${a.user_id.slice(0, 6)}`}
</span>
<div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-red-400"
onClick={() => handleRespond(a.id, false)}
disabled={busy}
>
<X className="h-4 w-4" />
</Button>
<Button
size="sm"
className="h-8 w-8 p-0"
onClick={() => handleRespond(a.id, true)}
disabled={busy}
>
<Check className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{acceptedAttendees.length > 0 && (
<div>
<p className="text-xs text-white/50 mb-2 uppercase tracking-wide">Going</p>
<div className="flex flex-wrap gap-2">
{acceptedAttendees.map((a) => (
<div
key={a.id}
className="h-9 w-9 rounded-full overflow-hidden border border-white/20 bg-white/10"
title={a.display_name ?? a.user_id}
>
{a.avatar_url ? (
<Image src={a.avatar_url} alt="" width={36} height={36} className="object-cover" unoptimized />
) : (
<div className="h-full w-full flex items-center justify-center text-xs text-white/60">
?
</div>
)}
</div>
))}
</div>
</div>
)}
{isHost && showInvite && (
<div className="rounded-xl border border-white/10 p-3 space-y-2">
<p className="text-xs text-white/60">Invite nearby cruisers</p>
{nearbyProfiles
.filter(
(p) =>
p.id !== event.host_id &&
!event.attendees.some((a) => a.user_id === p.id && a.status !== "cancelled")
)
.slice(0, 8)
.map((p) => (
<button
key={p.id}
type="button"
className="w-full flex items-center justify-between py-2 text-sm text-white hover:bg-white/5 rounded-lg px-2"
onClick={() => handleInvite(p.id)}
disabled={busy}
>
<span>{p.display_name ?? p.username ?? "Cruiser"}</span>
<UserPlus className="h-4 w-4 text-pulse-pink-light" />
</button>
))}
</div>
)}
</div>
</div>
<div className="shrink-0 p-3 border-t border-white/10 safe-bottom flex flex-wrap gap-2">
{isHost ? (
<>
<Button variant="secondary" className="flex-1" onClick={() => onEdit(event)}>
<Pencil className="h-4 w-4 mr-1" />
Edit
</Button>
<Button
variant="secondary"
className="flex-1"
onClick={() => setShowInvite(!showInvite)}
>
<UserPlus className="h-4 w-4 mr-1" />
Invite
</Button>
<Button
variant="ghost"
className="text-red-400"
onClick={handleCancelEvent}
disabled={busy}
>
Cancel event
</Button>
</>
) : myStatus === "accepted" ? (
<Button
variant="secondary"
className="w-full"
onClick={handleCancelAttendance}
disabled={busy}
>
Cancel RSVP
</Button>
) : myStatus === "requested" ? (
<Button variant="secondary" className="w-full" disabled>
Request pending
</Button>
) : myStatus === "invited" ? (
<Button className="w-full bg-pulse-gradient-bold" onClick={handleJoinFixed} disabled={busy || !userId}>
Accept invite
</Button>
) : (
<Button
className="w-full bg-pulse-gradient-bold"
onClick={handleJoinFixed}
disabled={busy || !userId || status === "ended"}
>
{event.auto_accept ? "Join event" : "Request to join"}
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -10,14 +10,24 @@ import { Slider } from "@/components/ui/slider";
import { CruiserProfileView } from "@/components/CruiserProfileView"; import { CruiserProfileView } from "@/components/CruiserProfileView";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { LocalGuidePanel } from "@/components/LocalGuidePanel"; import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { Filter, X, Locate } from "lucide-react"; import { CalendarPlus, Filter, X, Locate } from "lucide-react";
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types"; import type { Profile, CruisingSpot, HostingEvent, MapFilters, Position } from "@/types";
import { CreateHostingEventModal } from "@/components/CreateHostingEventModal";
import { HostingEventSheet } from "@/components/HostingEventSheet";
import { loadHostingEvents } from "@/lib/hosting-events-store";
import {
acceptedAttendeeCount,
EVENT_TYPE_META,
isEventVisibleOnMap,
resolveEventStatus,
} from "@/lib/hosting-events";
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, useAudienceView, useOpenIdVerification, useAccess } from "@/components/Providers"; import { useFeedPreferences, useAudienceView, useOpenIdVerification, useAccess } from "@/components/Providers";
import { profileMatchesAudienceView } from "@/lib/audience"; import { profileMatchesAudienceView } from "@/lib/audience";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data"; import { fetchLiveProfiles, fetchLiveSpots, findSpotById } from "@/lib/live-data";
import { SpotDetailSheet } from "@/components/SpotDetailSheet";
import { useAuth } from "@/lib/auth/context"; import { useAuth } from "@/lib/auth/context";
import { usePrivacy } from "@/contexts/PrivacyContext"; import { usePrivacy } from "@/contexts/PrivacyContext";
import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility"; import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
@@ -41,6 +51,14 @@ const Marker = dynamic(
() => import("react-leaflet").then((m) => m.Marker), () => import("react-leaflet").then((m) => m.Marker),
{ ssr: false } { ssr: false }
); );
const MapFlyTo = dynamic(
() => import("@/components/MapControls").then((m) => m.MapFlyTo),
{ ssr: false }
);
const MapSpotFocus = dynamic(
() => import("@/components/MapControls").then((m) => m.MapSpotFocus),
{ ssr: false }
);
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"); const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"); const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
@@ -50,31 +68,40 @@ const DARK_TILES =
interface MapProps { interface MapProps {
vanillaMode: boolean; vanillaMode: boolean;
onActiveCountChange?: (count: number) => void; onActiveCountChange?: (count: number) => void;
focusSpotId?: string | null;
} }
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { export function CruisingMap({ vanillaMode, onActiveCountChange, focusSpotId }: 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 { audienceView } = useAudienceView();
const { user, profile: authProfile } = useAuth(); const { user, profile: authProfile } = useAuth();
const { blockList } = usePrivacy(); const { blockList } = usePrivacy();
const [profiles] = useState<Profile[]>(MOCK_PROFILES); const [profiles, setProfiles] = useState<Profile[]>([]);
const viewer = useMemo( const viewer = useMemo(
() => (authProfile ? authProfile : guestViewerProfile()), () => (authProfile ? authProfile : guestViewerProfile()),
[authProfile] [authProfile]
); );
const viewerId = user?.id ?? "guest"; const viewerId = user?.id ?? "guest";
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS); const [spots, setSpots] = useState<CruisingSpot[]>([]);
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null); const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
const [selectedSpot, setSelectedSpot] = useState<CruisingSpot | null>(null);
const [recenterTick, setRecenterTick] = useState(0);
const [mapCenter, setMapCenter] = useState({ lat: DEFAULT_LAT, lng: DEFAULT_LNG });
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [leafletReady, setLeafletReady] = useState(false); const [leafletReady, setLeafletReady] = useState(false);
const [iconFactory, setIconFactory] = useState<{ const [iconFactory, setIconFactory] = useState<{
createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon; createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon;
createYou: () => import("leaflet").DivIcon; createYou: () => import("leaflet").DivIcon;
createSpot: (category: string, count: number) => import("leaflet").DivIcon; createSpot: (category: string, count: number) => import("leaflet").DivIcon;
createEvent: (event: HostingEvent) => import("leaflet").DivIcon;
} | null>(null); } | null>(null);
const [hostingEvents, setHostingEvents] = useState<HostingEvent[]>([]);
const [selectedEvent, setSelectedEvent] = useState<HostingEvent | null>(null);
const [createEventOpen, setCreateEventOpen] = useState(false);
const [editEvent, setEditEvent] = useState<HostingEvent | null>(null);
const [filters, setFilters] = useState<MapFilters>({ const [filters, setFilters] = useState<MapFilters>({
ageMin: 18, ageMin: 18,
@@ -84,8 +111,53 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
onPrep: null, onPrep: null,
status: [], status: [],
onlineOnly: true, onlineOnly: true,
showHostingEvents: true,
}); });
const refreshEvents = useCallback(() => {
loadHostingEvents().then(setHostingEvents);
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
const [liveProfiles, liveSpots] = await Promise.all([
fetchLiveProfiles(DEFAULT_LAT, DEFAULT_LNG),
fetchLiveSpots(DEFAULT_LAT, DEFAULT_LNG),
]);
if (!cancelled) {
setProfiles(liveProfiles);
setSpots(liveSpots);
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!focusSpotId || spots.length === 0) return;
const spot = findSpotById(spots, focusSpotId);
if (spot) {
setSelectedSpot(spot);
setMapCenter({ lat: spot.lat, lng: spot.lng });
}
}, [focusSpotId, spots]);
useEffect(() => {
refreshEvents();
const onChange = () => refreshEvents();
window.addEventListener("eg-hosting-events", onChange);
return () => window.removeEventListener("eg-hosting-events", onChange);
}, [refreshEvents]);
useEffect(() => {
if (!selectedEvent) return;
const fresh = hostingEvents.find((e) => e.id === selectedEvent.id);
if (!fresh) setSelectedEvent(null);
else if (fresh.updated_at !== selectedEvent.updated_at) setSelectedEvent(fresh);
}, [hostingEvents, selectedEvent?.id, selectedEvent?.updated_at]);
useEffect(() => { useEffect(() => {
import("leaflet").then((L) => { import("leaflet").then((L) => {
const shouldBlurAvatar = (p: Profile, blur: boolean) => { const shouldBlurAvatar = (p: Profile, blur: boolean) => {
@@ -159,11 +231,34 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
}); });
}; };
setIconFactory({ createPhoto, createYou, createSpot }); const createEvent = (event: HostingEvent) => {
const meta = EVENT_TYPE_META[event.event_type];
const live = resolveEventStatus(event) === "live";
const count = acceptedAttendeeCount(event);
return L.divIcon({
className: "sniffies-event-wrap",
html: `<div class="sniffies-event">
<div class="sniffies-event-pin ${live ? "sniffies-event-live" : ""}">${meta.emoji}</div>
${count > 0 ? `<div class="sniffies-event-count">${count}</div>` : ""}
</div>`,
iconSize: [48, 52],
iconAnchor: [24, 26],
});
};
setIconFactory({ createPhoto, createYou, createSpot, createEvent });
setLeafletReady(true); setLeafletReady(true);
}); });
}, []); }, []);
const visibleEvents = useMemo(
() =>
filters.showHostingEvents
? hostingEvents.filter(isEventVisibleOnMap)
: [],
[hostingEvents, filters.showHostingEvents]
);
const filteredProfiles = useMemo(() => { const filteredProfiles = useMemo(() => {
const base = profiles.filter((p) => { const base = profiles.filter((p) => {
if (filters.onlineOnly && p.status === "offline") return false; if (filters.onlineOnly && p.status === "offline") return false;
@@ -245,6 +340,20 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
> >
<Filter className="h-4 w-4" /> <Filter className="h-4 w-4" />
</Button> </Button>
{capabilities.canSendMessages && (
<Button
variant="secondary"
size="sm"
onClick={() => {
setEditEvent(null);
setCreateEventOpen(true);
}}
className="shadow-lg rounded-full bg-pulse-gradient-bold border-0 text-white h-9 gap-1.5 px-3"
>
<CalendarPlus className="h-4 w-4" />
<span className="text-xs font-bold">Host</span>
</Button>
)}
<Badge <Badge
variant="online" variant="online"
className="shadow-lg self-center bg-primary/20 text-pulse-pink-light border-primary/30" className="shadow-lg self-center bg-primary/20 text-pulse-pink-light border-primary/30"
@@ -330,6 +439,15 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
} }
/> />
</div> </div>
<div className="flex items-center justify-between">
<Label className="text-xs text-white/70">Hosting events on map</Label>
<Switch
checked={filters.showHostingEvents}
onCheckedChange={(v) =>
setFilters((f) => ({ ...f, showHostingEvents: v }))
}
/>
</div>
</div> </div>
)} )}
@@ -373,6 +491,17 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
url={DARK_TILES} url={DARK_TILES}
/> />
<MapFlyTo
lat={mapCenter.lat}
lng={mapCenter.lng}
trigger={recenterTick}
/>
<MapSpotFocus
lat={selectedSpot?.lat ?? null}
lng={selectedSpot?.lng ?? null}
spotId={focusSpotId ?? null}
/>
<Marker <Marker
position={[DEFAULT_LAT, DEFAULT_LNG]} position={[DEFAULT_LAT, DEFAULT_LNG]}
icon={iconFactory.createYou()} icon={iconFactory.createYou()}
@@ -399,23 +528,47 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
icon={iconFactory.createSpot(s.category, s.active_count)} icon={iconFactory.createSpot(s.category, s.active_count)}
eventHandlers={{ eventHandlers={{
click: () => { click: () => {
/* spot detail future */ setSelectedSpot(s);
setSelectedProfile(null);
setSelectedEvent(null);
}, },
}} }}
/> />
))} ))}
{visibleEvents.map((evt) => (
<Marker
key={evt.id}
position={[evt.lat, evt.lng]}
icon={iconFactory.createEvent(evt)}
zIndexOffset={500}
eventHandlers={{
click: () => setSelectedEvent(evt),
}}
/>
))}
</MapContainer> </MapContainer>
)} )}
{/* Recenter — above local guide dock on mobile */} {/* Recenter — above local guide dock on mobile */}
<button <button
type="button" type="button"
onClick={() => {
setMapCenter({ lat: DEFAULT_LAT, lng: DEFAULT_LNG });
setRecenterTick((t) => t + 1);
}}
className="absolute bottom-32 right-3 z-[1000] p-3 rounded-full bg-pulse-midnight/90 border border-primary/25 text-pulse-pink-light shadow-pulse-glow touch-manipulation md:bottom-6" className="absolute bottom-32 right-3 z-[1000] p-3 rounded-full bg-pulse-midnight/90 border border-primary/25 text-pulse-pink-light shadow-pulse-glow touch-manipulation md:bottom-6"
aria-label="Recenter map" aria-label="Recenter map"
> >
<Locate className="h-5 w-5" /> <Locate className="h-5 w-5" />
</button> </button>
<SpotDetailSheet
spot={selectedSpot}
open={!!selectedSpot}
onClose={() => setSelectedSpot(null)}
/>
<LocalGuidePanel placement="map_dock" variant="dock" /> <LocalGuidePanel placement="map_dock" variant="dock" />
<CruiserProfileView <CruiserProfileView
@@ -426,6 +579,39 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
vanillaMode={vanillaMode} vanillaMode={vanillaMode}
onVerifyClick={openIdVerification} onVerifyClick={openIdVerification}
/> />
<HostingEventSheet
event={selectedEvent}
open={!!selectedEvent}
onClose={() => setSelectedEvent(null)}
onEdit={(evt) => {
setSelectedEvent(null);
setEditEvent(evt);
setCreateEventOpen(true);
}}
onRefresh={refreshEvents}
nearbyProfiles={filteredProfiles}
/>
{user && (
<CreateHostingEventModal
open={createEventOpen}
onClose={() => {
setCreateEventOpen(false);
setEditEvent(null);
}}
onSaved={(evt) => {
refreshEvents();
setSelectedEvent(evt);
}}
hostId={user.id}
hostMeta={{
display_name: authProfile?.display_name,
avatar_url: authProfile?.avatar_url,
}}
editEvent={editEvent}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,44 @@
'use client';
import { useEffect } from 'react';
import { useMap } from 'react-leaflet';
/** Fly map to center when recenterTick changes. */
export function MapFlyTo({
lat,
lng,
zoom = 15,
trigger,
}: {
lat: number;
lng: number;
zoom?: number;
trigger: number;
}) {
const map = useMap();
useEffect(() => {
if (trigger > 0) {
map.flyTo([lat, lng], zoom, { duration: 0.8 });
}
}, [trigger, lat, lng, zoom, map]);
return null;
}
/** Initial fly-to for ?spot= deep links. */
export function MapSpotFocus({
lat,
lng,
spotId,
}: {
lat: number | null;
lng: number | null;
spotId: string | null;
}) {
const map = useMap();
useEffect(() => {
if (spotId && lat != null && lng != null) {
map.flyTo([lat, lng], 16, { duration: 1 });
}
}, [spotId, lat, lng, map]);
return null;
}

View File

@@ -1,12 +1,15 @@
"use client"; "use client";
import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; 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, LogIn } from "lucide-react";
import { AudienceSwitcher } from "@/components/AudienceSwitcher"; import { AudienceSwitcher } from "@/components/AudienceSwitcher";
import { useAudienceView } from "@/components/Providers"; import { useAudienceView } from "@/components/Providers";
import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal";
interface MobileHeaderProps { interface MobileHeaderProps {
vanillaMode: boolean; vanillaMode: boolean;
@@ -22,8 +25,11 @@ export function MobileHeader({
isGuest, isGuest,
}: MobileHeaderProps) { }: MobileHeaderProps) {
const { siteTitle } = useAudienceView(); const { siteTitle } = useAudienceView();
const { user } = useAuth();
const [authOpen, setAuthOpen] = useState(false);
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} />
@@ -48,7 +54,17 @@ export function MobileHeader({
> >
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} {vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button> </Button>
{isGuest ? ( {isGuest && !user ? (
<Button
variant="horny"
size="sm"
className="h-8 px-2 text-[10px] gap-1"
onClick={() => setAuthOpen(true)}
>
<LogIn className="h-3 w-3" />
Join
</Button>
) : isGuest ? (
<Badge variant="pill" className="text-[8px] px-1.5"> <Badge variant="pill" className="text-[8px] px-1.5">
Guest Guest
</Badge> </Badge>
@@ -59,5 +75,7 @@ export function MobileHeader({
)} )}
</div> </div>
</header> </header>
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
</>
); );
} }

View File

@@ -254,7 +254,13 @@ export function Providers({ children }: ProvidersProps) {
setVerified(localStorage.getItem("eg_age_verified") === "true"); setVerified(localStorage.getItem("eg_age_verified") === "true");
}, []); }, []);
if (!mounted) return null; if (!mounted) {
return (
<div className="min-h-screen min-h-[100dvh] bg-horny-dark flex items-center justify-center">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
);
}
return ( return (
<> <>

View File

@@ -10,7 +10,20 @@ export function SiteFooter() {
const pathname = usePathname(); const pathname = usePathname();
const isMapHome = pathname === "/"; const isMapHome = pathname === "/";
if (isMapHome) return null; if (isMapHome) {
return (
<footer className="hidden md:block border-t border-border/50 bg-horny-dark/60 mt-auto">
<div className="mx-auto max-w-5xl px-4 py-2 flex flex-wrap items-center justify-between gap-2 text-[10px] text-muted-foreground">
<span>© {new Date().getFullYear()} Just Two Roommates LLC · Free forever</span>
<div className="flex gap-3">
<Link href="/terms" className="hover:text-foreground">Terms</Link>
<Link href="/privacy" className="hover:text-foreground">Privacy</Link>
<Link href="/contact" className="hover:text-foreground">Contact</Link>
</div>
</div>
</footer>
);
}
return ( return (
<footer className="border-t border-border bg-horny-dark/80 mt-auto"> <footer className="border-t border-border bg-horny-dark/80 mt-auto">
@@ -52,6 +65,9 @@ export function SiteFooter() {
<Link href="/privacy" className="hover:text-foreground"> <Link href="/privacy" className="hover:text-foreground">
Privacy Privacy
</Link> </Link>
<Link href="/contact" className="hover:text-foreground">
Contact
</Link>
<Link <Link
href={JTR.url} href={JTR.url}
target="_blank" target="_blank"

View File

@@ -0,0 +1,80 @@
'use client';
import { MapPin, MessageCircle, Star, Users, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import type { CruisingSpot } from '@/types';
import Link from 'next/link';
const CATEGORY_LABELS: Record<string, string> = {
park: 'Park',
beach: 'Beach',
gym: 'Gym',
restroom: 'Restroom',
bookstore: 'Bookstore',
club: 'Club',
other: 'Other',
};
interface SpotDetailSheetProps {
spot: CruisingSpot | null;
open: boolean;
onClose: () => void;
}
export function SpotDetailSheet({ spot, open, onClose }: SpotDetailSheetProps) {
if (!spot || !open) return null;
return (
<div className="absolute bottom-24 left-3 right-3 z-[1001] md:left-auto md:right-6 md:max-w-sm">
<div className="rounded-2xl border border-primary/25 bg-pulse-midnight/98 backdrop-blur-md shadow-pulse-card p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<h3 className="font-bold text-white">{spot.name}</h3>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge variant="outline" className="text-[10px]">
{CATEGORY_LABELS[spot.category] ?? spot.category}
</Badge>
{spot.is_popular && (
<Badge className="text-[10px] bg-primary/20 text-pulse-pink-light">Popular</Badge>
)}
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-white/60 hover:text-white"
aria-label="Close"
>
<X className="h-4 w-4" />
</button>
</div>
{spot.description && (
<p className="text-xs text-white/75 leading-relaxed">{spot.description}</p>
)}
<div className="flex flex-wrap gap-3 text-[10px] text-white/60">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{spot.city}
</span>
<span className="flex items-center gap-1">
<Star className="h-3 w-3 text-yellow-400" />
{spot.rating}
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3 text-emerald-400" />
{spot.active_count} active
</span>
</div>
<div className="flex gap-2">
<Button variant="horny" size="sm" className="flex-1 gap-1" asChild>
<Link href={`/chat?spot=${spot.id}&room=${encodeURIComponent(spot.name)}`}>
<MessageCircle className="h-3.5 w-3.5" />
Spot Chat
</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,12 +1,13 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Search, MapPin, Star, Users, MessageCircle } from "lucide-react"; import { Search, MapPin, Star, Users, MessageCircle } from "lucide-react";
import { MOCK_SPOTS } from "@/lib/mock-data"; import { fetchLiveSpots } from "@/lib/live-data";
import type { CruisingSpot } from "@/types"; import type { CruisingSpot } from "@/types";
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_LABELS: Record<string, string> = {
@@ -20,10 +21,19 @@ const CATEGORY_LABELS: Record<string, string> = {
}; };
export function SpotDirectory() { export function SpotDirectory() {
const [spots, setSpots] = useState<CruisingSpot[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [category, setCategory] = useState<string | null>(null); const [category, setCategory] = useState<string | null>(null);
const filtered = MOCK_SPOTS.filter((s) => { useEffect(() => {
fetchLiveSpots().then((data) => {
setSpots(data);
setLoading(false);
});
}, []);
const filtered = spots.filter((s) => {
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false; if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false;
if (category && s.category !== category) return false; if (category && s.category !== category) return false;
return true; return true;
@@ -68,11 +78,18 @@ export function SpotDirectory() {
))} ))}
</div> </div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading spots</p>
) : (
<div className="space-y-3"> <div className="space-y-3">
{filtered.map((spot) => ( {filtered.map((spot) => (
<SpotCard key={spot.id} spot={spot} /> <SpotCard key={spot.id} spot={spot} />
))} ))}
{filtered.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No spots match.</p>
)}
</div> </div>
)}
</div> </div>
); );
} }
@@ -112,11 +129,13 @@ function SpotCard({ spot }: { spot: CruisingSpot }) {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Button variant="outline" size="sm" asChild> <Button variant="outline" size="sm" asChild>
<a href={`/?spot=${spot.id}`}>View Map</a> <Link href={`/?spot=${spot.id}`}>View Map</Link>
</Button> </Button>
<Button variant="horny" size="sm" className="gap-1"> <Button variant="horny" size="sm" className="gap-1" asChild>
<Link href={`/chat?spot=${spot.id}&room=${encodeURIComponent(spot.name)}`}>
<MessageCircle className="h-3 w-3" /> <MessageCircle className="h-3 w-3" />
Spot Chat Spot Chat
</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -130,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { error } = await supabase.auth.signUp({ const { error } = await supabase.auth.signUp({
email, email,
password, password,
options: { emailRedirectTo: `${window.location.origin}/profile` }, options: { emailRedirectTo: `${window.location.origin}/auth/callback?next=/profile` },
}); });
return { error: error?.message ?? null }; return { error: error?.message ?? null };
}; };
@@ -173,6 +173,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
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; if (data.orientation !== undefined) payload.orientation = data.orientation;
if (data.height_in !== undefined) payload.height_in = data.height_in;
if (data.weight_lb !== undefined) payload.weight_lb = data.weight_lb;
if (data.body_type !== undefined) payload.body_type = data.body_type;
if (data.community !== undefined) payload.community = data.community;
if (data.seeking !== undefined) payload.seeking = data.seeking;
saveProfileMeta(user.id, { saveProfileMeta(user.id, {
community: data.community, community: data.community,

View File

@@ -0,0 +1,486 @@
import { createClient } from "@/lib/supabase/client";
import {
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_PHOTOS,
MAX_EVENT_TITLE_LENGTH,
MAX_EVENT_VIDEOS,
} from "@/lib/limits";
import { MOCK_HOSTING_EVENTS } from "@/lib/mock-hosting-events";
import { resolveEventStatus } from "@/lib/hosting-events";
import type {
HostingAttendeeStatus,
HostingEvent,
HostingEventInput,
HostingEventMedia,
} from "@/types";
const LS_KEY = "eg_hosting_events";
export type EventMediaDraft = {
type: "photo" | "video";
url: string;
thumbnail_url?: string | null;
};
function uid() {
return crypto.randomUUID();
}
function notifyChange() {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("eg-hosting-events"));
}
}
function readLocal(): HostingEvent[] {
if (typeof window === "undefined") return MOCK_HOSTING_EVENTS;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) return JSON.parse(raw) as HostingEvent[];
localStorage.setItem(LS_KEY, JSON.stringify(MOCK_HOSTING_EVENTS));
return MOCK_HOSTING_EVENTS;
} catch {
return MOCK_HOSTING_EVENTS;
}
}
function writeLocal(events: HostingEvent[]) {
localStorage.setItem(LS_KEY, JSON.stringify(events));
notifyChange();
}
function withResolvedStatus(event: HostingEvent): HostingEvent {
return { ...event, status: resolveEventStatus(event) };
}
export async function loadHostingEvents(): Promise<HostingEvent[]> {
const local = readLocal().map(withResolvedStatus);
const supabase = createClient();
const { data, error } = await supabase
.from("hosting_events")
.select(
`
*,
hosting_event_media (*),
hosting_event_attendees (*)
`
)
.neq("status", "cancelled")
.order("starts_at");
if (error || !data?.length) return local;
return data.map((row) => mapDbEvent(row));
}
function mapDbEvent(row: Record<string, unknown>): HostingEvent {
const loc = row.location as { coordinates?: [number, number] } | null;
const media = (row.hosting_event_media as Record<string, unknown>[] | null) ?? [];
const attendees =
(row.hosting_event_attendees as Record<string, unknown>[] | null) ?? [];
const event: HostingEvent = {
id: row.id as string,
host_id: row.host_id as string,
title: row.title as string,
description: (row.description as string) ?? "",
event_type: row.event_type as HostingEvent["event_type"],
status: row.status as HostingEvent["status"],
starts_at: row.starts_at as string,
ends_at: row.ends_at as string,
lat: loc?.coordinates?.[1] ?? 0,
lng: loc?.coordinates?.[0] ?? 0,
city: (row.city as string) ?? null,
auto_accept: (row.auto_accept as boolean) ?? false,
max_guests: (row.max_guests as number) ?? null,
tags: (row.tags as string[]) ?? [],
created_at: row.created_at as string,
updated_at: row.updated_at as string,
media: media.map((m, i) => ({
id: m.id as string,
event_id: m.event_id as string,
media_type: m.media_type as "photo" | "video",
url: m.url as string,
thumbnail_url: (m.thumbnail_url as string) ?? null,
sort_order: (m.sort_order as number) ?? i,
created_at: m.created_at as string,
})),
attendees: attendees.map((a) => ({
id: a.id as string,
event_id: a.event_id as string,
user_id: a.user_id as string,
status: a.status as HostingAttendeeStatus,
created_at: a.created_at as string,
updated_at: a.updated_at as string,
})),
};
return withResolvedStatus(event);
}
export async function createHostingEvent(
input: HostingEventInput,
hostId: string,
hostMeta?: { display_name?: string | null; avatar_url?: string | null },
mediaDrafts: EventMediaDraft[] = []
): Promise<{ event?: HostingEvent; error?: string }> {
const title = input.title.trim().slice(0, MAX_EVENT_TITLE_LENGTH);
const description = input.description.trim().slice(0, MAX_EVENT_DESCRIPTION_LENGTH);
if (!title) return { error: "Title required." };
if (new Date(input.ends_at) <= new Date(input.starts_at)) {
return { error: "End time must be after start time." };
}
const photoCount = mediaDrafts.filter((m) => m.type === "photo").length;
const videoCount = mediaDrafts.filter((m) => m.type === "video").length;
if (photoCount > MAX_EVENT_PHOTOS) return { error: `Max ${MAX_EVENT_PHOTOS} photos.` };
if (videoCount > MAX_EVENT_VIDEOS) return { error: `Max ${MAX_EVENT_VIDEOS} video.` };
const eventId = uid();
const now = new Date().toISOString();
const media: HostingEventMedia[] = mediaDrafts.map((m, i) => ({
id: uid(),
event_id: eventId,
media_type: m.type,
url: m.url,
thumbnail_url: m.thumbnail_url ?? null,
sort_order: i,
created_at: now,
}));
const event: HostingEvent = withResolvedStatus({
id: eventId,
host_id: hostId,
title,
description,
event_type: input.event_type,
status: "scheduled",
starts_at: input.starts_at,
ends_at: input.ends_at,
lat: input.lat,
lng: input.lng,
city: input.city ?? null,
auto_accept: input.auto_accept,
max_guests: input.max_guests ?? null,
tags: input.tags ?? [],
created_at: now,
updated_at: now,
media,
attendees: [],
host_display_name: hostMeta?.display_name ?? null,
host_avatar_url: hostMeta?.avatar_url ?? null,
});
const all = readLocal();
writeLocal([event, ...all]);
const supabase = createClient();
const { error } = await supabase.from("hosting_events").insert({
id: eventId,
host_id: hostId,
title,
description,
event_type: input.event_type,
starts_at: input.starts_at,
ends_at: input.ends_at,
location: `SRID=4326;POINT(${input.lng} ${input.lat})`,
city: input.city ?? null,
auto_accept: input.auto_accept,
max_guests: input.max_guests ?? null,
tags: input.tags ?? [],
});
if (!error && media.length) {
await supabase.from("hosting_event_media").insert(
media.map((m) => ({
id: m.id,
event_id: eventId,
media_type: m.media_type,
url: m.url,
thumbnail_url: m.thumbnail_url,
sort_order: m.sort_order,
}))
);
}
return { event };
}
export async function updateHostingEvent(
eventId: string,
hostId: string,
patch: Partial<HostingEventInput> & { title?: string; description?: string },
mediaDrafts?: EventMediaDraft[]
): Promise<{ event?: HostingEvent; error?: string }> {
const all = readLocal();
const idx = all.findIndex((e) => e.id === eventId && e.host_id === hostId);
if (idx < 0) return { error: "Event not found." };
const current = all[idx];
const title = (patch.title ?? current.title).trim().slice(0, MAX_EVENT_TITLE_LENGTH);
const description = (patch.description ?? current.description)
.trim()
.slice(0, MAX_EVENT_DESCRIPTION_LENGTH);
const starts_at = patch.starts_at ?? current.starts_at;
const ends_at = patch.ends_at ?? current.ends_at;
if (new Date(ends_at) <= new Date(starts_at)) {
return { error: "End time must be after start time." };
}
let media = current.media;
if (mediaDrafts) {
const photoCount = mediaDrafts.filter((m) => m.type === "photo").length;
const videoCount = mediaDrafts.filter((m) => m.type === "video").length;
if (photoCount > MAX_EVENT_PHOTOS || videoCount > MAX_EVENT_VIDEOS) {
return { error: "Media limits exceeded." };
}
const now = new Date().toISOString();
media = mediaDrafts.map((m, i) => ({
id: uid(),
event_id: eventId,
media_type: m.type,
url: m.url,
thumbnail_url: m.thumbnail_url ?? null,
sort_order: i,
created_at: now,
}));
}
const updated: HostingEvent = withResolvedStatus({
...current,
title,
description,
event_type: patch.event_type ?? current.event_type,
starts_at,
ends_at,
lat: patch.lat ?? current.lat,
lng: patch.lng ?? current.lng,
city: patch.city !== undefined ? patch.city ?? null : current.city,
auto_accept: patch.auto_accept ?? current.auto_accept,
max_guests: patch.max_guests !== undefined ? patch.max_guests ?? null : current.max_guests,
tags: patch.tags ?? current.tags,
media,
updated_at: new Date().toISOString(),
});
all[idx] = updated;
writeLocal(all);
const supabase = createClient();
await supabase
.from("hosting_events")
.update({
title,
description,
event_type: updated.event_type,
starts_at,
ends_at,
city: updated.city,
auto_accept: updated.auto_accept,
max_guests: updated.max_guests,
tags: updated.tags,
updated_at: updated.updated_at,
...(patch.lat !== undefined && patch.lng !== undefined
? { location: `SRID=4326;POINT(${patch.lng} ${patch.lat})` }
: {}),
})
.eq("id", eventId)
.eq("host_id", hostId);
if (mediaDrafts) {
await supabase.from("hosting_event_media").delete().eq("event_id", eventId);
if (media.length) {
await supabase.from("hosting_event_media").insert(
media.map((m) => ({
id: m.id,
event_id: eventId,
media_type: m.media_type,
url: m.url,
thumbnail_url: m.thumbnail_url,
sort_order: m.sort_order,
}))
);
}
}
return { event: updated };
}
export async function cancelHostingEvent(
eventId: string,
hostId: string
): Promise<{ error?: string }> {
const all = readLocal();
const idx = all.findIndex((e) => e.id === eventId && e.host_id === hostId);
if (idx < 0) return { error: "Event not found." };
all[idx] = {
...all[idx],
status: "cancelled",
updated_at: new Date().toISOString(),
};
writeLocal(all.filter((e) => e.status !== "cancelled"));
const supabase = createClient();
await supabase
.from("hosting_events")
.update({ status: "cancelled", updated_at: new Date().toISOString() })
.eq("id", eventId)
.eq("host_id", hostId);
return {};
}
function upsertAttendeeLocal(
eventId: string,
userId: string,
status: HostingAttendeeStatus,
meta?: { display_name?: string | null; avatar_url?: string | null }
) {
const all = readLocal();
const idx = all.findIndex((e) => e.id === eventId);
if (idx < 0) return;
const event = all[idx];
const existing = event.attendees.find((a) => a.user_id === userId);
const now = new Date().toISOString();
const attendees = existing
? event.attendees.map((a) =>
a.user_id === userId ? { ...a, status, updated_at: now } : a
)
: [
...event.attendees,
{
id: uid(),
event_id: eventId,
user_id: userId,
status,
created_at: now,
updated_at: now,
display_name: meta?.display_name ?? null,
avatar_url: meta?.avatar_url ?? null,
},
];
all[idx] = { ...event, attendees, updated_at: now };
writeLocal(all);
}
export async function requestToJoinEvent(
eventId: string,
userId: string,
meta?: { display_name?: string | null; avatar_url?: string | null }
): Promise<{ status?: HostingAttendeeStatus; error?: string }> {
const all = readLocal();
const event = all.find((e) => e.id === eventId);
if (!event) return { error: "Event not found." };
if (event.host_id === userId) return { error: "You are the host." };
const existing = event.attendees.find((a) => a.user_id === userId);
if (existing?.status === "accepted") return { status: "accepted" };
if (existing?.status === "invited") {
upsertAttendeeLocal(eventId, userId, "accepted", meta);
return { status: "accepted" };
}
const status: HostingAttendeeStatus = event.auto_accept ? "accepted" : "requested";
upsertAttendeeLocal(eventId, userId, status, meta);
const supabase = createClient();
await supabase.from("hosting_event_attendees").upsert(
{
event_id: eventId,
user_id: userId,
status,
updated_at: new Date().toISOString(),
},
{ onConflict: "event_id,user_id" }
);
return { status };
}
export async function inviteToEvent(
eventId: string,
hostId: string,
inviteeId: string
): Promise<{ error?: string }> {
const all = readLocal();
const event = all.find((e) => e.id === eventId);
if (!event || event.host_id !== hostId) return { error: "Not authorized." };
upsertAttendeeLocal(eventId, inviteeId, "invited");
const supabase = createClient();
await supabase.from("hosting_event_attendees").upsert(
{
event_id: eventId,
user_id: inviteeId,
status: "invited",
updated_at: new Date().toISOString(),
},
{ onConflict: "event_id,user_id" }
);
return {};
}
export async function respondToAttendee(
eventId: string,
hostId: string,
attendeeId: string,
accept: boolean
): Promise<{ error?: string }> {
const all = readLocal();
const event = all.find((e) => e.id === eventId);
if (!event || event.host_id !== hostId) return { error: "Not authorized." };
const attendee = event.attendees.find((a) => a.id === attendeeId);
if (!attendee) return { error: "Attendee not found." };
const status: HostingAttendeeStatus = accept ? "accepted" : "declined";
upsertAttendeeLocal(eventId, attendee.user_id, status);
const supabase = createClient();
await supabase
.from("hosting_event_attendees")
.update({ status, updated_at: new Date().toISOString() })
.eq("id", attendeeId);
return {};
}
export async function cancelAttendance(
eventId: string,
userId: string
): Promise<{ error?: string }> {
upsertAttendeeLocal(eventId, userId, "cancelled");
const supabase = createClient();
await supabase
.from("hosting_event_attendees")
.update({ status: "cancelled", updated_at: new Date().toISOString() })
.eq("event_id", eventId)
.eq("user_id", userId);
return {};
}
export async function acceptInvite(
eventId: string,
userId: string
): Promise<{ error?: string }> {
upsertAttendeeLocal(eventId, userId, "accepted");
const supabase = createClient();
await supabase
.from("hosting_event_attendees")
.update({ status: "accepted", updated_at: new Date().toISOString() })
.eq("event_id", eventId)
.eq("user_id", userId);
return {};
}

64
src/lib/hosting-events.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { HostingEvent, HostingEventStatus, HostingEventType } from "@/types";
export const HOSTING_EVENT_TYPES: {
id: HostingEventType;
label: string;
emoji: string;
defaultTitle: string;
}[] = [
{ id: "cum-dump", label: "Cum Dump", emoji: "💦", defaultTitle: "Cum Dump Party" },
{ id: "orgy", label: "Orgy", emoji: "🔥", defaultTitle: "Orgy Tonight" },
{ id: "group", label: "Group", emoji: "👥", defaultTitle: "Group Play" },
{ id: "pnp", label: "PnP", emoji: "☁️", defaultTitle: "PnP Session" },
{ id: "vanilla", label: "Vanilla", emoji: "✨", defaultTitle: "Vanilla Meetup" },
{ id: "gloryhole", label: "Gloryhole", emoji: "🕳️", defaultTitle: "Gloryhole Open" },
{ id: "custom", label: "Custom", emoji: "📍", defaultTitle: "Hosting Event" },
];
export const EVENT_TYPE_META = Object.fromEntries(
HOSTING_EVENT_TYPES.map((t) => [t.id, t])
) as Record<HostingEventType, (typeof HOSTING_EVENT_TYPES)[number]>;
export function resolveEventStatus(
event: Pick<HostingEvent, "status" | "starts_at" | "ends_at">
): HostingEventStatus {
if (event.status === "cancelled" || event.status === "ended") return event.status;
const now = Date.now();
const start = new Date(event.starts_at).getTime();
const end = new Date(event.ends_at).getTime();
if (now >= end) return "ended";
if (now >= start) return "live";
return "scheduled";
}
export function acceptedAttendeeCount(event: HostingEvent): number {
return event.attendees.filter((a) => a.status === "accepted").length;
}
export function pendingRequestCount(event: HostingEvent): number {
return event.attendees.filter((a) => a.status === "requested").length;
}
export function viewerAttendeeStatus(
event: HostingEvent,
userId: string | null | undefined
) {
if (!userId) return null;
return event.attendees.find((a) => a.user_id === userId)?.status ?? null;
}
export function formatEventCountdown(startsAt: string): string {
const diff = new Date(startsAt).getTime() - Date.now();
if (diff <= 0) return "Now";
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ${mins % 60}m`;
const days = Math.floor(hrs / 24);
return `${days}d ${hrs % 24}h`;
}
export function isEventVisibleOnMap(event: HostingEvent): boolean {
const status = resolveEventStatus(event);
return status === "scheduled" || status === "live";
}

View File

@@ -9,5 +9,9 @@ export const MAX_PHOTO_SIZE_MB = 12;
export const MAX_VIDEO_SIZE_MB = 80; export const MAX_VIDEO_SIZE_MB = 80;
export const MAX_CANNED_MESSAGE_LENGTH = 500; export const MAX_CANNED_MESSAGE_LENGTH = 500;
export const MAX_ALBUM_NAME_LENGTH = 40; export const MAX_ALBUM_NAME_LENGTH = 40;
export const MAX_EVENT_PHOTOS = 2;
export const MAX_EVENT_VIDEOS = 1;
export const MAX_EVENT_TITLE_LENGTH = 80;
export const MAX_EVENT_DESCRIPTION_LENGTH = 2000;
export const IMAGE_OPTIMIZE_MAX_PX = 1920; export const IMAGE_OPTIMIZE_MAX_PX = 1920;
export const IMAGE_OPTIMIZE_QUALITY = 0.85; export const IMAGE_OPTIMIZE_QUALITY = 0.85;

111
src/lib/live-data.ts Normal file
View File

@@ -0,0 +1,111 @@
import { createClient } from "@/lib/supabase/client";
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
import type { CruisingSpot, Profile } from "@/types";
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
function mapDbProfile(row: Record<string, unknown>): Profile {
const loc = row.location as { coordinates?: [number, number] } | null;
return {
id: row.id as string,
username: (row.username as string) ?? null,
display_name: (row.display_name as string) ?? null,
age: (row.age as number) ?? null,
position: (row.position as Profile["position"]) ?? null,
bio: (row.bio as string) ?? null,
avatar_url: (row.avatar_url as string) ?? null,
status: (row.status as Profile["status"]) ?? "offline",
is_anonymous: (row.is_anonymous as boolean) ?? true,
kinks: (row.kinks as string[]) ?? [],
sti_status: (row.sti_status as string) ?? null,
on_prep: (row.on_prep as boolean) ?? false,
last_active: (row.last_active as string) ?? new Date().toISOString(),
lat: loc?.coordinates?.[1] ?? null,
lng: loc?.coordinates?.[0] ?? null,
city: (row.city as string) ?? null,
state: (row.state as string) ?? null,
id_verified: (row.id_verified as boolean) ?? false,
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(),
height_in: (row.height_in as number) ?? null,
weight_lb: (row.weight_lb as number) ?? null,
body_type: (row.body_type as string) ?? null,
orientation: (row.orientation as string) ?? null,
community: (row.community as Profile["community"]) ?? null,
seeking: (row.seeking as Profile["seeking"]) ?? null,
};
}
function mapDbSpot(row: Record<string, unknown>): CruisingSpot {
const loc = row.location as { coordinates?: [number, number] } | null;
return {
id: row.id as string,
name: row.name as string,
description: (row.description as string) ?? null,
category: row.category as CruisingSpot["category"],
lat: loc?.coordinates?.[1] ?? DEFAULT_LAT,
lng: loc?.coordinates?.[0] ?? DEFAULT_LNG,
city: row.city as string,
rating: Number(row.rating) || 0,
active_count: (row.active_count as number) ?? 0,
is_popular: (row.is_popular as boolean) ?? false,
created_at: (row.created_at as string) ?? new Date().toISOString(),
};
}
/** Live Supabase profiles merged with mock seed when DB is sparse (launch density). */
export async function fetchLiveProfiles(
lat = DEFAULT_LAT,
lng = DEFAULT_LNG
): Promise<Profile[]> {
try {
const supabase = createClient();
const { data, error } = await supabase.rpc("nearby_profiles", {
p_lat: lat,
p_lng: lng,
p_radius_km: 25,
});
if (!error && Array.isArray(data) && data.length > 0) {
const live = data.map((r) => mapDbProfile(r as Record<string, unknown>));
if (live.length >= 5) return live;
const liveIds = new Set(live.map((p) => p.id));
const filler = MOCK_PROFILES.filter((p) => !liveIds.has(p.id));
return [...live, ...filler].slice(0, 50);
}
} catch {
/* fall through */
}
return MOCK_PROFILES;
}
/** Live spots from DB with mock fallback. */
export async function fetchLiveSpots(
lat = DEFAULT_LAT,
lng = DEFAULT_LNG
): Promise<CruisingSpot[]> {
try {
const supabase = createClient();
const { data, error } = await supabase.rpc("nearby_spots", {
p_lat: lat,
p_lng: lng,
p_radius_km: 50,
});
if (!error && Array.isArray(data) && data.length > 0) {
return data.map((r) => mapDbSpot(r as Record<string, unknown>));
}
const { data: all } = await supabase.from("cruising_spots").select("*").limit(50);
if (all?.length) {
return all.map((r) => mapDbSpot(r as Record<string, unknown>));
}
} catch {
/* fall through */
}
return MOCK_SPOTS;
}
export function findSpotById(spots: CruisingSpot[], id: string | null): CruisingSpot | null {
if (!id) return null;
return spots.find((s) => s.id === id) ?? MOCK_SPOTS.find((s) => s.id === id) ?? null;
}

View File

@@ -0,0 +1,124 @@
import type { HostingEvent } from "@/types";
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
function hoursFromNow(h: number) {
return new Date(Date.now() + h * 3600000).toISOString();
}
function hoursAgo(h: number) {
return new Date(Date.now() - h * 3600000).toISOString();
}
export const MOCK_HOSTING_EVENTS: HostingEvent[] = [
{
id: "evt-1",
host_id: "1",
title: "Cum Dump — Wilton Drive",
description:
"Door unlocked. Clean only. Bring lube. Anonymous welcome. Text when outside — buzzer 4.",
event_type: "cum-dump",
status: "live",
starts_at: hoursAgo(1),
ends_at: hoursFromNow(3),
lat: DEFAULT_LAT + 0.004,
lng: DEFAULT_LNG - 0.003,
city: "Fort Lauderdale",
auto_accept: true,
max_guests: 12,
tags: ["anon", "oral", "group"],
created_at: hoursAgo(6),
updated_at: hoursAgo(1),
host_display_name: "Mike",
host_avatar_url:
"https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=200&h=200&fit=crop",
media: [
{
id: "em-1",
event_id: "evt-1",
media_type: "photo",
url: "https://images.unsplash.com/photo-1568602471122-7832951cc4c5?w=800&h=600&fit=crop",
thumbnail_url: null,
sort_order: 0,
created_at: hoursAgo(6),
},
{
id: "em-2",
event_id: "evt-1",
media_type: "photo",
url: "https://placehold.co/800x600/1a003a/f92b9b?text=Party+Setup",
thumbnail_url: null,
sort_order: 1,
created_at: hoursAgo(6),
},
],
attendees: [
{
id: "ea-1",
event_id: "evt-1",
user_id: "2",
status: "accepted",
created_at: hoursAgo(4),
updated_at: hoursAgo(4),
display_name: null,
avatar_url:
"https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?w=200&h=200&fit=crop",
},
{
id: "ea-2",
event_id: "evt-1",
user_id: "3",
status: "accepted",
created_at: hoursAgo(3),
updated_at: hoursAgo(3),
display_name: "Jay",
avatar_url:
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200&h=200&fit=crop",
},
{
id: "ea-3",
event_id: "evt-1",
user_id: "4",
status: "requested",
created_at: hoursAgo(2),
updated_at: hoursAgo(2),
display_name: "Chris",
avatar_url: null,
},
],
},
{
id: "evt-2",
host_id: "5",
title: "Saturday Orgy — Oakland Park",
description: "Pre-scheduled group session. Request to join — host approves. Condoms available.",
event_type: "orgy",
status: "scheduled",
starts_at: hoursFromNow(26),
ends_at: hoursFromNow(30),
lat: DEFAULT_LAT - 0.006,
lng: DEFAULT_LNG + 0.005,
city: "Oakland Park",
auto_accept: false,
max_guests: 8,
tags: ["group", "oral"],
created_at: hoursAgo(12),
updated_at: hoursAgo(12),
host_display_name: "Derek",
host_avatar_url:
"https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200&h=200&fit=crop",
media: [
{
id: "em-3",
event_id: "evt-2",
media_type: "photo",
url: "https://placehold.co/800x600/1a003a/8b22cc?text=Orgy+Night",
thumbnail_url: null,
sort_order: 0,
created_at: hoursAgo(12),
},
],
attendees: [],
},
];

View File

@@ -193,6 +193,83 @@ export interface MapFilters {
onPrep: boolean | null; onPrep: boolean | null;
status: UserStatus[]; status: UserStatus[];
onlineOnly: boolean; onlineOnly: boolean;
showHostingEvents: boolean;
}
export type HostingEventType =
| "cum-dump"
| "orgy"
| "group"
| "pnp"
| "vanilla"
| "gloryhole"
| "custom";
export type HostingEventStatus = "scheduled" | "live" | "ended" | "cancelled";
export type HostingAttendeeStatus =
| "invited"
| "requested"
| "accepted"
| "declined"
| "cancelled";
export interface HostingEventMedia {
id: string;
event_id: string;
media_type: "photo" | "video";
url: string;
thumbnail_url: string | null;
sort_order: number;
created_at: string;
}
export interface HostingEventAttendee {
id: string;
event_id: string;
user_id: string;
status: HostingAttendeeStatus;
created_at: string;
updated_at: string;
display_name?: string | null;
avatar_url?: string | null;
}
export interface HostingEvent {
id: string;
host_id: string;
title: string;
description: string;
event_type: HostingEventType;
status: HostingEventStatus;
starts_at: string;
ends_at: string;
lat: number;
lng: number;
city: string | null;
auto_accept: boolean;
max_guests: number | null;
tags: string[];
created_at: string;
updated_at: string;
media: HostingEventMedia[];
attendees: HostingEventAttendee[];
host_display_name?: string | null;
host_avatar_url?: string | null;
}
export interface HostingEventInput {
title: string;
description: string;
event_type: HostingEventType;
starts_at: string;
ends_at: string;
lat: number;
lng: number;
city?: string | null;
auto_accept: boolean;
max_guests?: number | null;
tags?: string[];
} }
/** @deprecated import INTEREST_OPTIONS from @/lib/interests */ /** @deprecated import INTEREST_OPTIONS from @/lib/interests */

View File

@@ -0,0 +1,163 @@
-- ExposedGays v6: Sniffies-style user hosting events (map parties)
-- Run on SUPABASE_01 after migration_v5_privacy.sql
CREATE TABLE IF NOT EXISTS public.hosting_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (char_length(title) <= 80),
description TEXT NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
event_type TEXT NOT NULL DEFAULT 'custom'
CHECK (event_type IN ('cum-dump', 'orgy', 'group', 'pnp', 'vanilla', 'gloryhole', 'custom')),
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'live', 'ended', 'cancelled')),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
location GEOGRAPHY(POINT, 4326) NOT NULL,
city TEXT,
auto_accept BOOLEAN NOT NULL DEFAULT false,
max_guests INTEGER CHECK (max_guests IS NULL OR max_guests BETWEEN 1 AND 500),
tags TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (ends_at > starts_at)
);
CREATE INDEX IF NOT EXISTS idx_hosting_events_host ON public.hosting_events (host_id);
CREATE INDEX IF NOT EXISTS idx_hosting_events_status ON public.hosting_events (status);
CREATE INDEX IF NOT EXISTS idx_hosting_events_starts ON public.hosting_events (starts_at);
CREATE INDEX IF NOT EXISTS idx_hosting_events_location ON public.hosting_events USING GIST (location);
CREATE TABLE IF NOT EXISTS public.hosting_event_media (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES public.hosting_events(id) ON DELETE CASCADE,
media_type TEXT NOT NULL CHECK (media_type IN ('photo', 'video')),
url TEXT NOT NULL,
thumbnail_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_hosting_event_media_event ON public.hosting_event_media (event_id);
CREATE TABLE IF NOT EXISTS public.hosting_event_attendees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES public.hosting_events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'requested'
CHECK (status IN ('invited', 'requested', 'accepted', 'declined', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_hosting_event_attendees_event ON public.hosting_event_attendees (event_id);
CREATE INDEX IF NOT EXISTS idx_hosting_event_attendees_user ON public.hosting_event_attendees (user_id);
-- Max 2 photos + 1 video per event
CREATE OR REPLACE FUNCTION public.enforce_hosting_event_media_limit()
RETURNS TRIGGER AS $$
DECLARE
photo_count INTEGER;
video_count INTEGER;
BEGIN
SELECT COUNT(*) INTO photo_count
FROM public.hosting_event_media
WHERE event_id = NEW.event_id AND media_type = 'photo';
SELECT COUNT(*) INTO video_count
FROM public.hosting_event_media
WHERE event_id = NEW.event_id AND media_type = 'video';
IF NEW.media_type = 'photo' AND photo_count >= 2 THEN
RAISE EXCEPTION 'Maximum 2 photos per hosting event';
END IF;
IF NEW.media_type = 'video' AND video_count >= 1 THEN
RAISE EXCEPTION 'Maximum 1 video per hosting event';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_hosting_event_media_limit ON public.hosting_event_media;
CREATE TRIGGER trg_hosting_event_media_limit
BEFORE INSERT ON public.hosting_event_media
FOR EACH ROW EXECUTE FUNCTION public.enforce_hosting_event_media_limit();
CREATE OR REPLACE FUNCTION public.touch_hosting_event_updated()
RETURNS TRIGGER AS $$
BEGIN
UPDATE public.hosting_events SET updated_at = now() WHERE id = NEW.event_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_hosting_event_attendee_touch ON public.hosting_event_attendees;
CREATE TRIGGER trg_hosting_event_attendee_touch
AFTER INSERT OR UPDATE ON public.hosting_event_attendees
FOR EACH ROW EXECUTE FUNCTION public.touch_hosting_event_updated();
ALTER TABLE public.hosting_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.hosting_event_media ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.hosting_event_attendees ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Hosting events viewable by authenticated users"
ON public.hosting_events FOR SELECT
USING (auth.role() = 'authenticated' AND status != 'cancelled');
CREATE POLICY "Users can create hosting events"
ON public.hosting_events FOR INSERT
WITH CHECK (auth.uid() = host_id);
CREATE POLICY "Hosts can update own events"
ON public.hosting_events FOR UPDATE
USING (auth.uid() = host_id);
CREATE POLICY "Hosts can delete own events"
ON public.hosting_events FOR DELETE
USING (auth.uid() = host_id);
CREATE POLICY "Event media viewable by authenticated users"
ON public.hosting_event_media FOR SELECT
USING (auth.role() = 'authenticated');
CREATE POLICY "Hosts manage event media"
ON public.hosting_event_media FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.hosting_events e
WHERE e.id = event_id AND e.host_id = auth.uid()
)
);
CREATE POLICY "Attendees viewable by authenticated users"
ON public.hosting_event_attendees FOR SELECT
USING (auth.role() = 'authenticated');
CREATE POLICY "Users manage own attendance"
ON public.hosting_event_attendees FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users update own attendance"
ON public.hosting_event_attendees FOR UPDATE
USING (auth.uid() = user_id OR EXISTS (
SELECT 1 FROM public.hosting_events e
WHERE e.id = event_id AND e.host_id = auth.uid()
));
CREATE POLICY "Hosts delete attendees"
ON public.hosting_event_attendees FOR DELETE
USING (
auth.uid() = user_id OR EXISTS (
SELECT 1 FROM public.hosting_events e
WHERE e.id = event_id AND e.host_id = auth.uid()
)
);
ALTER PUBLICATION supabase_realtime ADD TABLE public.hosting_events;
ALTER PUBLICATION supabase_realtime ADD TABLE public.hosting_event_attendees;
INSERT INTO storage.buckets (id, name, public)
VALUES ('hosting-events', 'hosting-events', true)
ON CONFLICT (id) DO NOTHING;