Go-live: live data wiring, auth callback, SEO, legal pages, UI fixes
This commit is contained in:
@@ -30,6 +30,9 @@ Open [http://localhost:3000](http://localhost:3000)
|
|||||||
| Vanilla mode blur toggle | ✅ |
|
| Vanilla mode blur toggle | ✅ |
|
||||||
| Dark mode, mobile-first, PWA manifest | ✅ |
|
| Dark mode, mobile-first, PWA manifest | ✅ |
|
||||||
| Anonymous browsing + optional profile | ✅ |
|
| Anonymous browsing + optional profile | ✅ |
|
||||||
|
| **Hosting events** (Sniffies-style map parties) | ✅ |
|
||||||
|
| Schedule ahead · 2 photos + 1 video · invites | ✅ |
|
||||||
|
| Request/auto-accept · edit · cancel · RSVP | ✅ |
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
|
|||||||
44
deploy/apply-supabase-functions.py
Normal file
44
deploy/apply-supabase-functions.py
Normal 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.")
|
||||||
91
deploy/retry-stalled-tasks.py
Normal file
91
deploy/retry-stalled-tasks.py
Normal 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()
|
||||||
@@ -4,6 +4,22 @@ import type { PulseTrackPayload } from "@/lib/pulse-analytics";
|
|||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
const rateBucket = new Map<string, { count: number; reset: number }>();
|
||||||
|
const RATE_LIMIT = 60;
|
||||||
|
const RATE_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
function checkRateLimit(key: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = rateBucket.get(key);
|
||||||
|
if (!entry || now > entry.reset) {
|
||||||
|
rateBucket.set(key, { count: 1, reset: now + RATE_WINDOW_MS });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.count >= RATE_LIMIT) return false;
|
||||||
|
entry.count += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function adminClient() {
|
function adminClient() {
|
||||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
@@ -24,6 +40,15 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ ok: false, error: "missing_fields" }, { status: 400 });
|
return NextResponse.json({ ok: false, error: "missing_fields" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ip =
|
||||||
|
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
req.headers.get("x-real-ip") ||
|
||||||
|
"unknown";
|
||||||
|
const rateKey = `${ip}:${payload.session_id ?? "anon"}`;
|
||||||
|
if (!checkRateLimit(rateKey)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "rate_limited" }, { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = adminClient();
|
const supabase = adminClient();
|
||||||
if (!supabase) {
|
if (!supabase) {
|
||||||
return NextResponse.json({ ok: true, stored: false });
|
return NextResponse.json({ ok: true, stored: false });
|
||||||
|
|||||||
18
src/app/auth/callback/route.ts
Normal file
18
src/app/auth/callback/route.ts
Normal 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`);
|
||||||
|
}
|
||||||
@@ -7,10 +7,12 @@ import { ChatPanel } from "@/components/ChatPanel";
|
|||||||
function ChatContent() {
|
function ChatContent() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const userId = searchParams.get("user");
|
const userId = searchParams.get("user");
|
||||||
|
const spotId = searchParams.get("spot");
|
||||||
|
const spotRoom = searchParams.get("room");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100dvh-7rem)] md:h-[calc(100dvh-3.5rem)]">
|
<div className="h-[calc(100dvh-7rem)] md:h-[calc(100dvh-3.5rem)]">
|
||||||
<ChatPanel dmUserId={userId} />
|
<ChatPanel dmUserId={userId} spotId={spotId} spotRoomName={spotRoom} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/app/contact/page.tsx
Normal file
82
src/app/contact/page.tsx
Normal 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -209,6 +209,63 @@
|
|||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sniffies-event-wrap {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-event {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-event-pin {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(145deg, #ff2d9b 0%, #8b22cc 100%);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 0 0 2px rgba(249, 43, 155, 0.4), 0 4px 14px rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-event-pin.sniffies-event-live {
|
||||||
|
animation: sniffies-event-pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sniffies-event-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 2px rgba(249, 43, 155, 0.4), 0 4px 14px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(249, 43, 155, 0.25), 0 4px 18px rgba(249, 43, 155, 0.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sniffies-event-count {
|
||||||
|
margin-top: 2px;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #1a003a;
|
||||||
|
border: 1px solid rgba(249, 43, 155, 0.5);
|
||||||
|
color: #ff6ec7;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.leaflet-popup-content-wrapper {
|
.leaflet-popup-content-wrapper {
|
||||||
background: hsl(268 100% 11%);
|
background: hsl(268 100% 11%);
|
||||||
color: hsl(228 100% 97%);
|
color: hsl(228 100% 97%);
|
||||||
|
|||||||
@@ -8,11 +8,31 @@ const manrope = Manrope({
|
|||||||
variable: "--font-manrope",
|
variable: "--font-manrope",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://exposedgays.com";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "ExposedGays — Free Gay Cruising & Shop",
|
metadataBase: new URL(siteUrl),
|
||||||
|
title: {
|
||||||
|
default: "ExposedGays — Free Gay Cruising & Shop",
|
||||||
|
template: "%s · ExposedGays",
|
||||||
|
},
|
||||||
description:
|
description:
|
||||||
"100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.",
|
"100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.",
|
||||||
manifest: "/manifest.json",
|
manifest: "/manifest.json",
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "en_US",
|
||||||
|
url: siteUrl,
|
||||||
|
siteName: "ExposedGays",
|
||||||
|
title: "ExposedGays — Free Gay Cruising & Shop",
|
||||||
|
description:
|
||||||
|
"100% free gay cruising map, chat, spots directory, and sex shop. No subscriptions ever.",
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "ExposedGays — Free Gay Cruising",
|
||||||
|
description: "Free gay cruising map, chat & spots. No subscriptions ever.",
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/logo.svg",
|
icon: "/logo.svg",
|
||||||
apple: "/logo.svg",
|
apple: "/logo.svg",
|
||||||
|
|||||||
21
src/app/not-found.tsx
Normal file
21
src/app/not-found.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import HomeMap from "@/components/HomeMap";
|
||||||
import { CruisingMap } from "@/components/Map";
|
|
||||||
import { useSetActiveCount, useVanillaMode } from "@/components/Providers";
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const setActiveCount = useSetActiveCount();
|
|
||||||
const vanillaMode = useVanillaMode();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] -mx-0 flex-1">
|
<Suspense
|
||||||
<CruisingMap
|
fallback={
|
||||||
vanillaMode={vanillaMode}
|
<div className="h-[calc(100dvh-4rem)] md:h-[calc(100dvh-3.5rem)] flex items-center justify-center text-muted-foreground text-sm">
|
||||||
onActiveCountChange={setActiveCount}
|
Loading map…
|
||||||
/>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
|
<HomeMap />
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,112 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Privacy Policy — ExposedGays',
|
||||||
|
};
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert">
|
<div className="mx-auto max-w-2xl px-4 py-8 space-y-6 text-sm text-muted-foreground leading-relaxed">
|
||||||
<h1>Privacy Policy</h1>
|
<h1 className="text-2xl font-bold text-foreground">Privacy Policy</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-xs text-muted-foreground">Last updated: June 27, 2026</p>
|
||||||
ExposedGays is operated by Just Two Roommates LLC. We collect minimal data.
|
|
||||||
Location is used for map features and city chat auto-join. Anonymous browsing
|
<section className="space-y-2">
|
||||||
is supported. Cart data stays in your browser (localStorage). When you follow
|
<h2 className="text-lg font-semibold text-foreground">Who we are</h2>
|
||||||
links to our community partner The Pink Pulse, their privacy policy applies on
|
<p>
|
||||||
their site. Placeholder policy — consult legal counsel before launch.
|
ExposedGays is operated by <strong>Just Two Roommates LLC</strong>, Wilton Manors, FL.
|
||||||
|
Contact:{' '}
|
||||||
|
<a href="mailto:support@justtworoommates.com" className="text-primary hover:underline">
|
||||||
|
support@justtworoommates.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">What we collect</h2>
|
||||||
|
<ul className="list-disc pl-5 space-y-1">
|
||||||
|
<li>
|
||||||
|
<strong>Account:</strong> email (if you sign up), profile fields you choose to share
|
||||||
|
(age, photos, bio, interests, health info you enter).
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Location:</strong> approximate map coordinates for cruising features and city
|
||||||
|
chat auto-join. You can browse with limited location precision.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Device:</strong> browser type, session IDs for analytics, age-gate state in
|
||||||
|
localStorage.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Messages:</strong> chat content stored when you use city, spot, or DM features
|
||||||
|
while signed in.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">What stays on your device</h2>
|
||||||
|
<p>
|
||||||
|
Guest browsing stores profile drafts, block lists, hide rules, and cart items in{' '}
|
||||||
|
<strong>localStorage</strong> until you create an account. Signed-in users sync profile
|
||||||
|
and gallery to our database (Supabase, self-hosted).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Photos & 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 & 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 & Safety
|
||||||
|
</Link>{' '}
|
||||||
|
·{' '}
|
||||||
|
<Link href="/terms" className="text-primary hover:underline">
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
13
src/app/robots.ts
Normal file
13
src/app/robots.ts
Normal 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
16
src/app/sitemap.ts
Normal 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 },
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -1,15 +1,107 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Terms of Service — ExposedGays',
|
||||||
|
};
|
||||||
|
|
||||||
export default function TermsPage() {
|
export default function TermsPage() {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl px-4 py-8 prose prose-invert">
|
<div className="mx-auto max-w-2xl px-4 py-8 space-y-6 text-sm text-muted-foreground leading-relaxed">
|
||||||
<h1>Terms of Service</h1>
|
<h1 className="text-2xl font-bold text-foreground">Terms of Service</h1>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-xs text-muted-foreground">Last updated: June 27, 2026</p>
|
||||||
ExposedGays is a free adult platform operated by Just Two Roommates LLC. You must
|
|
||||||
be 18+ to use this site. All content is user-generated. We reserve the right to
|
<section className="space-y-2">
|
||||||
remove illegal content. No paid subscriptions — ever. Links to our community
|
<h2 className="text-lg font-semibold text-foreground">1. Operator</h2>
|
||||||
partner The Pink Pulse (thepinkpulse.com) are provided for LGBTQ+ news, events,
|
<p>
|
||||||
and local resources — separate editorial property, same parent company.
|
ExposedGays.com is operated by <strong>Just Two Roommates LLC</strong> ("we,"
|
||||||
Placeholder terms — consult legal counsel before launch.
|
"us"). This is an adult-only platform for gay and queer men and aligned
|
||||||
</p>
|
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's terms.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">4. User content & 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 & 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 "as is." Meet other users at your own risk. We do not
|
||||||
|
conduct criminal background checks. Report abuse to{' '}
|
||||||
|
<a href="mailto:abuse@justtworoommates.com" className="text-primary hover:underline">
|
||||||
|
abuse@justtworoommates.com
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">9. Contact</h2>
|
||||||
|
<p>
|
||||||
|
Questions:{' '}
|
||||||
|
<Link href="/contact" className="text-primary hover:underline">
|
||||||
|
Contact page
|
||||||
|
</Link>{' '}
|
||||||
|
or{' '}
|
||||||
|
<a href="mailto:support@justtworoommates.com" className="text-primary hover:underline">
|
||||||
|
support@justtworoommates.com
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,16 +75,20 @@ const MOCK_DM_MESSAGES: Message[] = [
|
|||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
dmUserId?: string | null;
|
dmUserId?: string | null;
|
||||||
|
spotId?: string | null;
|
||||||
|
spotRoomName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
|
||||||
const { capabilities } = useAccess();
|
const { capabilities } = useAccess();
|
||||||
const { user, profile: authProfile } = useAuth();
|
const { user, profile: authProfile } = useAuth();
|
||||||
const { blockList } = usePrivacy();
|
const { blockList } = usePrivacy();
|
||||||
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
|
const [cityMessages, setCityMessages] = useState<Message[]>(MOCK_CITY_MESSAGES);
|
||||||
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
const [dmMessages, setDmMessages] = useState<Message[]>(MOCK_DM_MESSAGES);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState(dmUserId ? "dm" : "city");
|
const [activeTab, setActiveTab] = useState(
|
||||||
|
dmUserId ? "dm" : spotId ? "city" : "city"
|
||||||
|
);
|
||||||
const [showQuickReplies, setShowQuickReplies] = useState(false);
|
const [showQuickReplies, setShowQuickReplies] = useState(false);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -161,7 +165,9 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {
|
|||||||
{cityMessages.length + 12} in room
|
{cityMessages.length + 12} in room
|
||||||
</Badge>
|
</Badge>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Auto-joined based on your location. Public city chat.
|
{spotId && spotRoomName
|
||||||
|
? `Spot chat — ${decodeURIComponent(spotRoomName)}. Also connected to ${CITY} city room.`
|
||||||
|
: "Auto-joined based on your location. Public city chat."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<MessageList messages={messages} />
|
<MessageList messages={messages} />
|
||||||
|
|||||||
343
src/components/CreateHostingEventModal.tsx
Normal file
343
src/components/CreateHostingEventModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/HomeMap.tsx
Normal file
22
src/components/HomeMap.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
364
src/components/HostingEventSheet.tsx
Normal file
364
src/components/HostingEventSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,14 +10,24 @@ import { Slider } from "@/components/ui/slider";
|
|||||||
import { CruiserProfileView } from "@/components/CruiserProfileView";
|
import { CruiserProfileView } from "@/components/CruiserProfileView";
|
||||||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
||||||
import { Filter, X, Locate } from "lucide-react";
|
import { CalendarPlus, Filter, X, Locate } from "lucide-react";
|
||||||
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
|
import type { Profile, CruisingSpot, HostingEvent, MapFilters, Position } from "@/types";
|
||||||
|
import { CreateHostingEventModal } from "@/components/CreateHostingEventModal";
|
||||||
|
import { HostingEventSheet } from "@/components/HostingEventSheet";
|
||||||
|
import { loadHostingEvents } from "@/lib/hosting-events-store";
|
||||||
|
import {
|
||||||
|
acceptedAttendeeCount,
|
||||||
|
EVENT_TYPE_META,
|
||||||
|
isEventVisibleOnMap,
|
||||||
|
resolveEventStatus,
|
||||||
|
} from "@/lib/hosting-events";
|
||||||
import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests";
|
import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests";
|
||||||
import { InterestTicker } from "@/components/InterestTicker";
|
import { InterestTicker } from "@/components/InterestTicker";
|
||||||
import { InterestPreviewRail } from "@/components/InterestPreviewRail";
|
import { InterestPreviewRail } from "@/components/InterestPreviewRail";
|
||||||
import { useFeedPreferences, useAudienceView, useOpenIdVerification, useAccess } from "@/components/Providers";
|
import { useFeedPreferences, useAudienceView, useOpenIdVerification, useAccess } from "@/components/Providers";
|
||||||
import { profileMatchesAudienceView } from "@/lib/audience";
|
import { profileMatchesAudienceView } from "@/lib/audience";
|
||||||
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
import { fetchLiveProfiles, fetchLiveSpots, findSpotById } from "@/lib/live-data";
|
||||||
|
import { SpotDetailSheet } from "@/components/SpotDetailSheet";
|
||||||
import { useAuth } from "@/lib/auth/context";
|
import { useAuth } from "@/lib/auth/context";
|
||||||
import { usePrivacy } from "@/contexts/PrivacyContext";
|
import { usePrivacy } from "@/contexts/PrivacyContext";
|
||||||
import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
|
import { canViewerMessageTarget, filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
|
||||||
@@ -41,6 +51,14 @@ const Marker = dynamic(
|
|||||||
() => import("react-leaflet").then((m) => m.Marker),
|
() => import("react-leaflet").then((m) => m.Marker),
|
||||||
{ ssr: false }
|
{ ssr: false }
|
||||||
);
|
);
|
||||||
|
const MapFlyTo = dynamic(
|
||||||
|
() => import("@/components/MapControls").then((m) => m.MapFlyTo),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
const MapSpotFocus = dynamic(
|
||||||
|
() => import("@/components/MapControls").then((m) => m.MapSpotFocus),
|
||||||
|
{ ssr: false }
|
||||||
|
);
|
||||||
|
|
||||||
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
|
const DEFAULT_LAT = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224");
|
||||||
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
|
const DEFAULT_LNG = parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373");
|
||||||
@@ -50,31 +68,40 @@ const DARK_TILES =
|
|||||||
interface MapProps {
|
interface MapProps {
|
||||||
vanillaMode: boolean;
|
vanillaMode: boolean;
|
||||||
onActiveCountChange?: (count: number) => void;
|
onActiveCountChange?: (count: number) => void;
|
||||||
|
focusSpotId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
export function CruisingMap({ vanillaMode, onActiveCountChange, focusSpotId }: MapProps) {
|
||||||
const openIdVerification = useOpenIdVerification();
|
const openIdVerification = useOpenIdVerification();
|
||||||
const { capabilities, blurExplicit } = useAccess();
|
const { capabilities, blurExplicit } = useAccess();
|
||||||
const { feedInterests } = useFeedPreferences();
|
const { feedInterests } = useFeedPreferences();
|
||||||
const { audienceView } = useAudienceView();
|
const { audienceView } = useAudienceView();
|
||||||
const { user, profile: authProfile } = useAuth();
|
const { user, profile: authProfile } = useAuth();
|
||||||
const { blockList } = usePrivacy();
|
const { blockList } = usePrivacy();
|
||||||
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
|
|
||||||
const viewer = useMemo(
|
const viewer = useMemo(
|
||||||
() => (authProfile ? authProfile : guestViewerProfile()),
|
() => (authProfile ? authProfile : guestViewerProfile()),
|
||||||
[authProfile]
|
[authProfile]
|
||||||
);
|
);
|
||||||
const viewerId = user?.id ?? "guest";
|
const viewerId = user?.id ?? "guest";
|
||||||
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
|
const [spots, setSpots] = useState<CruisingSpot[]>([]);
|
||||||
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
||||||
|
const [selectedSpot, setSelectedSpot] = useState<CruisingSpot | null>(null);
|
||||||
|
const [recenterTick, setRecenterTick] = useState(0);
|
||||||
|
const [mapCenter, setMapCenter] = useState({ lat: DEFAULT_LAT, lng: DEFAULT_LNG });
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
const [leafletReady, setLeafletReady] = useState(false);
|
const [leafletReady, setLeafletReady] = useState(false);
|
||||||
const [iconFactory, setIconFactory] = useState<{
|
const [iconFactory, setIconFactory] = useState<{
|
||||||
createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon;
|
createPhoto: (p: Profile, blur: boolean) => import("leaflet").DivIcon;
|
||||||
createYou: () => import("leaflet").DivIcon;
|
createYou: () => import("leaflet").DivIcon;
|
||||||
createSpot: (category: string, count: number) => import("leaflet").DivIcon;
|
createSpot: (category: string, count: number) => import("leaflet").DivIcon;
|
||||||
|
createEvent: (event: HostingEvent) => import("leaflet").DivIcon;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [hostingEvents, setHostingEvents] = useState<HostingEvent[]>([]);
|
||||||
|
const [selectedEvent, setSelectedEvent] = useState<HostingEvent | null>(null);
|
||||||
|
const [createEventOpen, setCreateEventOpen] = useState(false);
|
||||||
|
const [editEvent, setEditEvent] = useState<HostingEvent | null>(null);
|
||||||
|
|
||||||
const [filters, setFilters] = useState<MapFilters>({
|
const [filters, setFilters] = useState<MapFilters>({
|
||||||
ageMin: 18,
|
ageMin: 18,
|
||||||
@@ -84,8 +111,53 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
onPrep: null,
|
onPrep: null,
|
||||||
status: [],
|
status: [],
|
||||||
onlineOnly: true,
|
onlineOnly: true,
|
||||||
|
showHostingEvents: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const refreshEvents = useCallback(() => {
|
||||||
|
loadHostingEvents().then(setHostingEvents);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [liveProfiles, liveSpots] = await Promise.all([
|
||||||
|
fetchLiveProfiles(DEFAULT_LAT, DEFAULT_LNG),
|
||||||
|
fetchLiveSpots(DEFAULT_LAT, DEFAULT_LNG),
|
||||||
|
]);
|
||||||
|
if (!cancelled) {
|
||||||
|
setProfiles(liveProfiles);
|
||||||
|
setSpots(liveSpots);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusSpotId || spots.length === 0) return;
|
||||||
|
const spot = findSpotById(spots, focusSpotId);
|
||||||
|
if (spot) {
|
||||||
|
setSelectedSpot(spot);
|
||||||
|
setMapCenter({ lat: spot.lat, lng: spot.lng });
|
||||||
|
}
|
||||||
|
}, [focusSpotId, spots]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshEvents();
|
||||||
|
const onChange = () => refreshEvents();
|
||||||
|
window.addEventListener("eg-hosting-events", onChange);
|
||||||
|
return () => window.removeEventListener("eg-hosting-events", onChange);
|
||||||
|
}, [refreshEvents]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedEvent) return;
|
||||||
|
const fresh = hostingEvents.find((e) => e.id === selectedEvent.id);
|
||||||
|
if (!fresh) setSelectedEvent(null);
|
||||||
|
else if (fresh.updated_at !== selectedEvent.updated_at) setSelectedEvent(fresh);
|
||||||
|
}, [hostingEvents, selectedEvent?.id, selectedEvent?.updated_at]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import("leaflet").then((L) => {
|
import("leaflet").then((L) => {
|
||||||
const shouldBlurAvatar = (p: Profile, blur: boolean) => {
|
const shouldBlurAvatar = (p: Profile, blur: boolean) => {
|
||||||
@@ -159,11 +231,34 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
setIconFactory({ createPhoto, createYou, createSpot });
|
const createEvent = (event: HostingEvent) => {
|
||||||
|
const meta = EVENT_TYPE_META[event.event_type];
|
||||||
|
const live = resolveEventStatus(event) === "live";
|
||||||
|
const count = acceptedAttendeeCount(event);
|
||||||
|
return L.divIcon({
|
||||||
|
className: "sniffies-event-wrap",
|
||||||
|
html: `<div class="sniffies-event">
|
||||||
|
<div class="sniffies-event-pin ${live ? "sniffies-event-live" : ""}">${meta.emoji}</div>
|
||||||
|
${count > 0 ? `<div class="sniffies-event-count">${count}</div>` : ""}
|
||||||
|
</div>`,
|
||||||
|
iconSize: [48, 52],
|
||||||
|
iconAnchor: [24, 26],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setIconFactory({ createPhoto, createYou, createSpot, createEvent });
|
||||||
setLeafletReady(true);
|
setLeafletReady(true);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const visibleEvents = useMemo(
|
||||||
|
() =>
|
||||||
|
filters.showHostingEvents
|
||||||
|
? hostingEvents.filter(isEventVisibleOnMap)
|
||||||
|
: [],
|
||||||
|
[hostingEvents, filters.showHostingEvents]
|
||||||
|
);
|
||||||
|
|
||||||
const filteredProfiles = useMemo(() => {
|
const filteredProfiles = useMemo(() => {
|
||||||
const base = profiles.filter((p) => {
|
const base = profiles.filter((p) => {
|
||||||
if (filters.onlineOnly && p.status === "offline") return false;
|
if (filters.onlineOnly && p.status === "offline") return false;
|
||||||
@@ -245,6 +340,20 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
>
|
>
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{capabilities.canSendMessages && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setEditEvent(null);
|
||||||
|
setCreateEventOpen(true);
|
||||||
|
}}
|
||||||
|
className="shadow-lg rounded-full bg-pulse-gradient-bold border-0 text-white h-9 gap-1.5 px-3"
|
||||||
|
>
|
||||||
|
<CalendarPlus className="h-4 w-4" />
|
||||||
|
<span className="text-xs font-bold">Host</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant="online"
|
variant="online"
|
||||||
className="shadow-lg self-center bg-primary/20 text-pulse-pink-light border-primary/30"
|
className="shadow-lg self-center bg-primary/20 text-pulse-pink-light border-primary/30"
|
||||||
@@ -330,6 +439,15 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-white/70">Hosting events on map</Label>
|
||||||
|
<Switch
|
||||||
|
checked={filters.showHostingEvents}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setFilters((f) => ({ ...f, showHostingEvents: v }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -373,6 +491,17 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
url={DARK_TILES}
|
url={DARK_TILES}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MapFlyTo
|
||||||
|
lat={mapCenter.lat}
|
||||||
|
lng={mapCenter.lng}
|
||||||
|
trigger={recenterTick}
|
||||||
|
/>
|
||||||
|
<MapSpotFocus
|
||||||
|
lat={selectedSpot?.lat ?? null}
|
||||||
|
lng={selectedSpot?.lng ?? null}
|
||||||
|
spotId={focusSpotId ?? null}
|
||||||
|
/>
|
||||||
|
|
||||||
<Marker
|
<Marker
|
||||||
position={[DEFAULT_LAT, DEFAULT_LNG]}
|
position={[DEFAULT_LAT, DEFAULT_LNG]}
|
||||||
icon={iconFactory.createYou()}
|
icon={iconFactory.createYou()}
|
||||||
@@ -399,23 +528,47 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
icon={iconFactory.createSpot(s.category, s.active_count)}
|
icon={iconFactory.createSpot(s.category, s.active_count)}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => {
|
click: () => {
|
||||||
/* spot detail future */
|
setSelectedSpot(s);
|
||||||
|
setSelectedProfile(null);
|
||||||
|
setSelectedEvent(null);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{visibleEvents.map((evt) => (
|
||||||
|
<Marker
|
||||||
|
key={evt.id}
|
||||||
|
position={[evt.lat, evt.lng]}
|
||||||
|
icon={iconFactory.createEvent(evt)}
|
||||||
|
zIndexOffset={500}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => setSelectedEvent(evt),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Recenter — above local guide dock on mobile */}
|
{/* Recenter — above local guide dock on mobile */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMapCenter({ lat: DEFAULT_LAT, lng: DEFAULT_LNG });
|
||||||
|
setRecenterTick((t) => t + 1);
|
||||||
|
}}
|
||||||
className="absolute bottom-32 right-3 z-[1000] p-3 rounded-full bg-pulse-midnight/90 border border-primary/25 text-pulse-pink-light shadow-pulse-glow touch-manipulation md:bottom-6"
|
className="absolute bottom-32 right-3 z-[1000] p-3 rounded-full bg-pulse-midnight/90 border border-primary/25 text-pulse-pink-light shadow-pulse-glow touch-manipulation md:bottom-6"
|
||||||
aria-label="Recenter map"
|
aria-label="Recenter map"
|
||||||
>
|
>
|
||||||
<Locate className="h-5 w-5" />
|
<Locate className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<SpotDetailSheet
|
||||||
|
spot={selectedSpot}
|
||||||
|
open={!!selectedSpot}
|
||||||
|
onClose={() => setSelectedSpot(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<LocalGuidePanel placement="map_dock" variant="dock" />
|
<LocalGuidePanel placement="map_dock" variant="dock" />
|
||||||
|
|
||||||
<CruiserProfileView
|
<CruiserProfileView
|
||||||
@@ -426,6 +579,39 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
vanillaMode={vanillaMode}
|
vanillaMode={vanillaMode}
|
||||||
onVerifyClick={openIdVerification}
|
onVerifyClick={openIdVerification}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<HostingEventSheet
|
||||||
|
event={selectedEvent}
|
||||||
|
open={!!selectedEvent}
|
||||||
|
onClose={() => setSelectedEvent(null)}
|
||||||
|
onEdit={(evt) => {
|
||||||
|
setSelectedEvent(null);
|
||||||
|
setEditEvent(evt);
|
||||||
|
setCreateEventOpen(true);
|
||||||
|
}}
|
||||||
|
onRefresh={refreshEvents}
|
||||||
|
nearbyProfiles={filteredProfiles}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<CreateHostingEventModal
|
||||||
|
open={createEventOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateEventOpen(false);
|
||||||
|
setEditEvent(null);
|
||||||
|
}}
|
||||||
|
onSaved={(evt) => {
|
||||||
|
refreshEvents();
|
||||||
|
setSelectedEvent(evt);
|
||||||
|
}}
|
||||||
|
hostId={user.id}
|
||||||
|
hostMeta={{
|
||||||
|
display_name: authProfile?.display_name,
|
||||||
|
avatar_url: authProfile?.avatar_url,
|
||||||
|
}}
|
||||||
|
editEvent={editEvent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
44
src/components/MapControls.tsx
Normal file
44
src/components/MapControls.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff, LogIn } from "lucide-react";
|
||||||
import { AudienceSwitcher } from "@/components/AudienceSwitcher";
|
import { AudienceSwitcher } from "@/components/AudienceSwitcher";
|
||||||
import { useAudienceView } from "@/components/Providers";
|
import { useAudienceView } from "@/components/Providers";
|
||||||
|
import { useAuth } from "@/lib/auth/context";
|
||||||
|
import { AuthModal } from "@/components/AuthModal";
|
||||||
|
|
||||||
interface MobileHeaderProps {
|
interface MobileHeaderProps {
|
||||||
vanillaMode: boolean;
|
vanillaMode: boolean;
|
||||||
@@ -22,8 +25,11 @@ export function MobileHeader({
|
|||||||
isGuest,
|
isGuest,
|
||||||
}: MobileHeaderProps) {
|
}: MobileHeaderProps) {
|
||||||
const { siteTitle } = useAudienceView();
|
const { siteTitle } = useAudienceView();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className="md:hidden fixed top-0 left-0 right-0 z-50 flex h-12 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-3 safe-top">
|
<header className="md:hidden fixed top-0 left-0 right-0 z-50 flex h-12 items-center justify-between border-b border-border bg-horny-dark/95 backdrop-blur-md px-3 safe-top">
|
||||||
<Link href="/" className="flex items-center gap-1.5 min-w-0">
|
<Link href="/" className="flex items-center gap-1.5 min-w-0">
|
||||||
<Logo size="sm" showText={false} />
|
<Logo size="sm" showText={false} />
|
||||||
@@ -48,7 +54,17 @@ export function MobileHeader({
|
|||||||
>
|
>
|
||||||
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{vanillaMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
{isGuest ? (
|
{isGuest && !user ? (
|
||||||
|
<Button
|
||||||
|
variant="horny"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-[10px] gap-1"
|
||||||
|
onClick={() => setAuthOpen(true)}
|
||||||
|
>
|
||||||
|
<LogIn className="h-3 w-3" />
|
||||||
|
Join
|
||||||
|
</Button>
|
||||||
|
) : isGuest ? (
|
||||||
<Badge variant="pill" className="text-[8px] px-1.5">
|
<Badge variant="pill" className="text-[8px] px-1.5">
|
||||||
Guest
|
Guest
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -59,5 +75,7 @@ export function MobileHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<AuthModal open={authOpen} onClose={() => setAuthOpen(false)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -254,7 +254,13 @@ export function Providers({ children }: ProvidersProps) {
|
|||||||
setVerified(localStorage.getItem("eg_age_verified") === "true");
|
setVerified(localStorage.getItem("eg_age_verified") === "true");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen min-h-[100dvh] bg-horny-dark flex items-center justify-center">
|
||||||
|
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,7 +10,20 @@ export function SiteFooter() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isMapHome = pathname === "/";
|
const isMapHome = pathname === "/";
|
||||||
|
|
||||||
if (isMapHome) return null;
|
if (isMapHome) {
|
||||||
|
return (
|
||||||
|
<footer className="hidden md:block border-t border-border/50 bg-horny-dark/60 mt-auto">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-2 flex flex-wrap items-center justify-between gap-2 text-[10px] text-muted-foreground">
|
||||||
|
<span>© {new Date().getFullYear()} Just Two Roommates LLC · Free forever</span>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link href="/terms" className="hover:text-foreground">Terms</Link>
|
||||||
|
<Link href="/privacy" className="hover:text-foreground">Privacy</Link>
|
||||||
|
<Link href="/contact" className="hover:text-foreground">Contact</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-border bg-horny-dark/80 mt-auto">
|
<footer className="border-t border-border bg-horny-dark/80 mt-auto">
|
||||||
@@ -52,6 +65,9 @@ export function SiteFooter() {
|
|||||||
<Link href="/privacy" className="hover:text-foreground">
|
<Link href="/privacy" className="hover:text-foreground">
|
||||||
Privacy
|
Privacy
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link href="/contact" className="hover:text-foreground">
|
||||||
|
Contact
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={JTR.url}
|
href={JTR.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
80
src/components/SpotDetailSheet.tsx
Normal file
80
src/components/SpotDetailSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Search, MapPin, Star, Users, MessageCircle } from "lucide-react";
|
import { Search, MapPin, Star, Users, MessageCircle } from "lucide-react";
|
||||||
import { MOCK_SPOTS } from "@/lib/mock-data";
|
import { fetchLiveSpots } from "@/lib/live-data";
|
||||||
import type { CruisingSpot } from "@/types";
|
import type { CruisingSpot } from "@/types";
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<string, string> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
@@ -20,10 +21,19 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function SpotDirectory() {
|
export function SpotDirectory() {
|
||||||
|
const [spots, setSpots] = useState<CruisingSpot[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [category, setCategory] = useState<string | null>(null);
|
const [category, setCategory] = useState<string | null>(null);
|
||||||
|
|
||||||
const filtered = MOCK_SPOTS.filter((s) => {
|
useEffect(() => {
|
||||||
|
fetchLiveSpots().then((data) => {
|
||||||
|
setSpots(data);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filtered = spots.filter((s) => {
|
||||||
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false;
|
if (search && !s.name.toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
if (category && s.category !== category) return false;
|
if (category && s.category !== category) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -68,11 +78,18 @@ export function SpotDirectory() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
{loading ? (
|
||||||
{filtered.map((spot) => (
|
<p className="text-sm text-muted-foreground">Loading spots…</p>
|
||||||
<SpotCard key={spot.id} spot={spot} />
|
) : (
|
||||||
))}
|
<div className="space-y-3">
|
||||||
</div>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,11 +129,13 @@ function SpotCard({ spot }: { spot: CruisingSpot }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<a href={`/?spot=${spot.id}`}>View Map</a>
|
<Link href={`/?spot=${spot.id}`}>View Map</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="horny" size="sm" className="gap-1">
|
<Button variant="horny" size="sm" className="gap-1" asChild>
|
||||||
<MessageCircle className="h-3 w-3" />
|
<Link href={`/chat?spot=${spot.id}&room=${encodeURIComponent(spot.name)}`}>
|
||||||
Spot Chat
|
<MessageCircle className="h-3 w-3" />
|
||||||
|
Spot Chat
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const { error } = await supabase.auth.signUp({
|
const { error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: { emailRedirectTo: `${window.location.origin}/profile` },
|
options: { emailRedirectTo: `${window.location.origin}/auth/callback?next=/profile` },
|
||||||
});
|
});
|
||||||
return { error: error?.message ?? null };
|
return { error: error?.message ?? null };
|
||||||
};
|
};
|
||||||
@@ -173,6 +173,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (data.id_verified !== undefined) payload.id_verified = data.id_verified;
|
if (data.id_verified !== undefined) payload.id_verified = data.id_verified;
|
||||||
if (data.id_verified_at !== undefined) payload.id_verified_at = data.id_verified_at;
|
if (data.id_verified_at !== undefined) payload.id_verified_at = data.id_verified_at;
|
||||||
if (data.orientation !== undefined) payload.orientation = data.orientation;
|
if (data.orientation !== undefined) payload.orientation = data.orientation;
|
||||||
|
if (data.height_in !== undefined) payload.height_in = data.height_in;
|
||||||
|
if (data.weight_lb !== undefined) payload.weight_lb = data.weight_lb;
|
||||||
|
if (data.body_type !== undefined) payload.body_type = data.body_type;
|
||||||
|
if (data.community !== undefined) payload.community = data.community;
|
||||||
|
if (data.seeking !== undefined) payload.seeking = data.seeking;
|
||||||
|
|
||||||
saveProfileMeta(user.id, {
|
saveProfileMeta(user.id, {
|
||||||
community: data.community,
|
community: data.community,
|
||||||
|
|||||||
486
src/lib/hosting-events-store.ts
Normal file
486
src/lib/hosting-events-store.ts
Normal 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
64
src/lib/hosting-events.ts
Normal 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";
|
||||||
|
}
|
||||||
@@ -9,5 +9,9 @@ export const MAX_PHOTO_SIZE_MB = 12;
|
|||||||
export const MAX_VIDEO_SIZE_MB = 80;
|
export const MAX_VIDEO_SIZE_MB = 80;
|
||||||
export const MAX_CANNED_MESSAGE_LENGTH = 500;
|
export const MAX_CANNED_MESSAGE_LENGTH = 500;
|
||||||
export const MAX_ALBUM_NAME_LENGTH = 40;
|
export const MAX_ALBUM_NAME_LENGTH = 40;
|
||||||
|
export const MAX_EVENT_PHOTOS = 2;
|
||||||
|
export const MAX_EVENT_VIDEOS = 1;
|
||||||
|
export const MAX_EVENT_TITLE_LENGTH = 80;
|
||||||
|
export const MAX_EVENT_DESCRIPTION_LENGTH = 2000;
|
||||||
export const IMAGE_OPTIMIZE_MAX_PX = 1920;
|
export const IMAGE_OPTIMIZE_MAX_PX = 1920;
|
||||||
export const IMAGE_OPTIMIZE_QUALITY = 0.85;
|
export const IMAGE_OPTIMIZE_QUALITY = 0.85;
|
||||||
111
src/lib/live-data.ts
Normal file
111
src/lib/live-data.ts
Normal 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;
|
||||||
|
}
|
||||||
124
src/lib/mock-hosting-events.ts
Normal file
124
src/lib/mock-hosting-events.ts
Normal 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: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -193,6 +193,83 @@ export interface MapFilters {
|
|||||||
onPrep: boolean | null;
|
onPrep: boolean | null;
|
||||||
status: UserStatus[];
|
status: UserStatus[];
|
||||||
onlineOnly: boolean;
|
onlineOnly: boolean;
|
||||||
|
showHostingEvents: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostingEventType =
|
||||||
|
| "cum-dump"
|
||||||
|
| "orgy"
|
||||||
|
| "group"
|
||||||
|
| "pnp"
|
||||||
|
| "vanilla"
|
||||||
|
| "gloryhole"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
export type HostingEventStatus = "scheduled" | "live" | "ended" | "cancelled";
|
||||||
|
|
||||||
|
export type HostingAttendeeStatus =
|
||||||
|
| "invited"
|
||||||
|
| "requested"
|
||||||
|
| "accepted"
|
||||||
|
| "declined"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
export interface HostingEventMedia {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
media_type: "photo" | "video";
|
||||||
|
url: string;
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostingEventAttendee {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
user_id: string;
|
||||||
|
status: HostingAttendeeStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
display_name?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostingEvent {
|
||||||
|
id: string;
|
||||||
|
host_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
event_type: HostingEventType;
|
||||||
|
status: HostingEventStatus;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
city: string | null;
|
||||||
|
auto_accept: boolean;
|
||||||
|
max_guests: number | null;
|
||||||
|
tags: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
media: HostingEventMedia[];
|
||||||
|
attendees: HostingEventAttendee[];
|
||||||
|
host_display_name?: string | null;
|
||||||
|
host_avatar_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostingEventInput {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
event_type: HostingEventType;
|
||||||
|
starts_at: string;
|
||||||
|
ends_at: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
city?: string | null;
|
||||||
|
auto_accept: boolean;
|
||||||
|
max_guests?: number | null;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated import INTEREST_OPTIONS from @/lib/interests */
|
/** @deprecated import INTEREST_OPTIONS from @/lib/interests */
|
||||||
|
|||||||
163
supabase/migration_v6_hosting_events.sql
Normal file
163
supabase/migration_v6_hosting_events.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user