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 | ✅ |
| Dark mode, mobile-first, PWA manifest | ✅ |
| 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

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";
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() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
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 });
}
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();
if (!supabase) {
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() {
const searchParams = useSearchParams();
const userId = searchParams.get("user");
const spotId = searchParams.get("spot");
const spotRoom = searchParams.get("room");
return (
<div className="h-[calc(100dvh-7rem)] md:h-[calc(100dvh-3.5rem)]">
<ChatPanel dmUserId={userId} />
<ChatPanel dmUserId={userId} spotId={spotId} spotRoomName={spotRoom} />
</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;
}
.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 {
background: hsl(268 100% 11%);
color: hsl(228 100% 97%);

View File

@@ -8,11 +8,31 @@ const manrope = Manrope({
variable: "--font-manrope",
});
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://exposedgays.com";
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:
"100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.",
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: {
icon: "/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 { CruisingMap } from "@/components/Map";
import { useSetActiveCount, useVanillaMode } from "@/components/Providers";
import { Suspense } from "react";
import HomeMap from "@/components/HomeMap";
export default function HomePage() {
const setActiveCount = useSetActiveCount();
const vanillaMode = useVanillaMode();
return (
<div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] -mx-0 flex-1">
<CruisingMap
vanillaMode={vanillaMode}
onActiveCountChange={setActiveCount}
/>
</div>
<Suspense
fallback={
<div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] flex items-center justify-center text-muted-foreground text-sm">
Loading map
</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() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert">
<h1>Privacy Policy</h1>
<p className="text-muted-foreground text-sm">
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
is supported. Cart data stays in your browser (localStorage). When you follow
links to our community partner The Pink Pulse, their privacy policy applies on
their site. Placeholder policy consult legal counsel before launch.
<div className="mx-auto max-w-2xl px-4 py-8 space-y-6 text-sm text-muted-foreground leading-relaxed">
<h1 className="text-2xl font-bold text-foreground">Privacy Policy</h1>
<p className="text-xs text-muted-foreground">Last updated: June 27, 2026</p>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">Who we are</h2>
<p>
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>
</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() {
return (
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert">
<h1>Terms of Service</h1>
<p className="text-muted-foreground text-sm">
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
remove illegal content. No paid subscriptions ever. Links to our community
partner The Pink Pulse (thepinkpulse.com) are provided for LGBTQ+ news, events,
and local resources separate editorial property, same parent company.
Placeholder terms consult legal counsel before launch.
</p>
<div className="mx-auto max-w-2xl px-4 py-8 space-y-6 text-sm text-muted-foreground leading-relaxed">
<h1 className="text-2xl font-bold text-foreground">Terms of Service</h1>
<p className="text-xs text-muted-foreground">Last updated: June 27, 2026</p>
<section className="space-y-2">
<h2 className="text-lg font-semibold text-foreground">1. Operator</h2>
<p>
ExposedGays.com is operated by <strong>Just Two Roommates LLC</strong> (&quot;we,&quot;
&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>
</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>
);
}

View File

@@ -75,16 +75,20 @@ const MOCK_DM_MESSAGES: Message[] = [
interface ChatPanelProps {
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 { user, profile: authProfile } = useAuth();
const { blockList } = usePrivacy();
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
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 bottomRef = useRef<HTMLDivElement>(null);
@@ -161,7 +165,9 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
{cityMessages.length + 12} in room
</Badge>
<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>
</div>
<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 { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { Filter, X, Locate } from "lucide-react";
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { CalendarPlus, Filter, X, Locate } from "lucide-react";
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 { InterestTicker } from "@/components/InterestTicker";
import { InterestPreviewRail } from "@/components/InterestPreviewRail";
import { useFeedPreferences, useAudienceView, useOpenIdVerification, useAccess } from "@/components/Providers";
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 { usePrivacy } from "@/contexts/PrivacyContext";
import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
@@ -41,6 +51,14 @@ const Marker = dynamic(
() => import("react-leaflet").then((m) => m.Marker),
{ 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_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
@@ -50,31 +68,40 @@ const DARK_TILES =
interface MapProps {
vanillaMode: boolean;
onActiveCountChange?: (count: number) => void;
focusSpotId?: string | null;
}
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
export function CruisingMap({ vanillaMode, onActiveCountChange, focusSpotId }: MapProps) {
const openIdVerification = useOpenIdVerification();
const { capabilities, blurExplicit } = useAccess();
const { feedInterests } = useFeedPreferences();
const { audienceView } = useAudienceView();
const { user, profile: authProfile } = useAuth();
const { blockList } = usePrivacy();
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
const [profiles, setProfiles] = useState<Profile[]>([]);
const viewer = useMemo(
() => (authProfile ? authProfile : guestViewerProfile()),
[authProfile]
);
const viewerId = user?.id ?? "guest";
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
const [spots, setSpots] = useState<CruisingSpot[]>([]);
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 [leafletReady, setLeafletReady] = useState(false);
const [iconFactory, setIconFactory] = useState<{
createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon;
createYou: () => import("leaflet").DivIcon;
createSpot: (category: string, count: number) => import("leaflet").DivIcon;
createEvent: (event: HostingEvent) => import("leaflet").DivIcon;
} | 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>({
ageMin: 18,
@@ -84,8 +111,53 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
onPrep: null,
status: [],
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(() => {
import("leaflet").then((L) => {
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);
});
}, []);
const visibleEvents = useMemo(
() =>
filters.showHostingEvents
? hostingEvents.filter(isEventVisibleOnMap)
: [],
[hostingEvents, filters.showHostingEvents]
);
const filteredProfiles = useMemo(() => {
const base = profiles.filter((p) => {
if (filters.onlineOnly && p.status === "offline") return false;
@@ -245,6 +340,20 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
>
<Filter className="h-4 w-4" />
</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
variant="online"
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 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>
)}
@@ -373,6 +491,17 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
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
position={[DEFAULT_LAT, DEFAULT_LNG]}
icon={iconFactory.createYou()}
@@ -399,23 +528,47 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
icon={iconFactory.createSpot(s.category, s.active_count)}
eventHandlers={{
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>
)}
{/* Recenter — above local guide dock on mobile */}
<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"
aria-label="Recenter map"
>
<Locate className="h-5 w-5" />
</button>
<SpotDetailSheet
spot={selectedSpot}
open={!!selectedSpot}
onClose={() => setSelectedSpot(null)}
/>
<LocalGuidePanel placement="map_dock" variant="dock" />
<CruiserProfileView
@@ -426,6 +579,39 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
vanillaMode={vanillaMode}
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>
);
}

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";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Logo } from "@/components/Logo";
import { Eye, EyeOff } from "lucide-react";
import { Eye, EyeOff, LogIn } from "lucide-react";
import { AudienceSwitcher } from "@/components/AudienceSwitcher";
import { useAudienceView } from "@/components/Providers";
import { useAuth } from "@/lib/auth/context";
import { AuthModal } from "@/components/AuthModal";
interface MobileHeaderProps {
vanillaMode: boolean;
@@ -22,8 +25,11 @@ export function MobileHeader({
isGuest,
}: MobileHeaderProps) {
const { siteTitle } = useAudienceView();
const { user } = useAuth();
const [authOpen, setAuthOpen] = useState(false);
return (
<>
<header className="md:hidden fixed top-0 left-0 right-0 z-50 flex h-12 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-3 safe-top">
<Link href="/" className="flex items-center gap-1.5 min-w-0">
<Logo size="sm" showText={false} />
@@ -48,7 +54,17 @@ export function MobileHeader({
>
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</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">
Guest
</Badge>
@@ -59,5 +75,7 @@ export function MobileHeader({
)}
</div>
</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");
}, []);
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 (
<>

View File

@@ -10,7 +10,20 @@ export function SiteFooter() {
const pathname = usePathname();
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 (
<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">
Privacy
</Link>
<Link href="/contact" className="hover:text-foreground">
Contact
</Link>
<Link
href={JTR.url}
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";
import { useState } from "react";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
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";
const CATEGORY_LABELS: Record<string, string> = {
@@ -20,10 +21,19 @@ const CATEGORY_LABELS: Record<string, string> = {
};
export function SpotDirectory() {
const [spots, setSpots] = useState<CruisingSpot[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
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 (category && s.category !== category) return false;
return true;
@@ -68,11 +78,18 @@ export function SpotDirectory() {
))}
</div>
<div className="space-y-3">
{filtered.map((spot) => (
<SpotCard key={spot.id} spot={spot} />
))}
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Loading spots</p>
) : (
<div className="space-y-3">
{filtered.map((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>
);
}
@@ -112,11 +129,13 @@ function SpotCard({ spot }: { spot: CruisingSpot }) {
</div>
<div className="flex flex-col gap-2">
<Button variant="outline" size="sm" asChild>
<a href={`/?spot=${spot.id}`}>View Map</a>
<Link href={`/?spot=${spot.id}`}>View Map</Link>
</Button>
<Button variant="horny" size="sm" className="gap-1">
<MessageCircle className="h-3 w-3" />
Spot Chat
<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" />
Spot Chat
</Link>
</Button>
</div>
</div>

View File

@@ -130,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { error } = await supabase.auth.signUp({
email,
password,
options: { emailRedirectTo: `${window.location.origin}/profile` },
options: { emailRedirectTo: `${window.location.origin}/auth/callback?next=/profile` },
});
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_at !== undefined) payload.id_verified_at = data.id_verified_at;
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, {
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_CANNED_MESSAGE_LENGTH = 500;
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_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;
status: UserStatus[];
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 */

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;