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