ExposedGays: map, shop, auth, Supabase schema, Coolify Dockerfile

This commit is contained in:
Ryan Salazar
2026-06-26 17:32:15 -04:00
commit 3eb12c8b34
54 changed files with 4424 additions and 0 deletions

189
src/lib/auth/context.tsx Normal file
View File

@@ -0,0 +1,189 @@
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import { createClient } from "@/lib/supabase/client";
import type { User, Session } from "@supabase/supabase-js";
import type { Profile } from "@/types";
interface AuthContextValue {
user: User | null;
session: Session | null;
profile: Profile | null;
loading: boolean;
signUp: (email: string, password: string) => Promise<{ error: string | null }>;
signIn: (email: string, password: string) => Promise<{ error: string | null }>;
signInAnonymously: () => Promise<{ error: string | null }>;
signOut: () => Promise<void>;
updateProfile: (data: Partial<Profile>) => Promise<{ error: string | null }>;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
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,
vanilla_mode: (row.vanilla_mode as boolean) ?? false,
created_at: (row.created_at as string) ?? new Date().toISOString(),
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const supabase = createClient();
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true);
const fetchProfile = useCallback(
async (userId: string) => {
const { data, error } = await supabase
.from("profiles")
.select("*")
.eq("id", userId)
.single();
if (!error && data) {
setProfile(mapDbProfile(data));
}
},
[supabase]
);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session: s } }) => {
setSession(s);
setUser(s?.user ?? null);
if (s?.user) fetchProfile(s.user.id);
setLoading(false);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setUser(s?.user ?? null);
if (s?.user) {
fetchProfile(s.user.id);
} else {
setProfile(null);
}
});
return () => subscription.unsubscribe();
}, [supabase, fetchProfile]);
const signUp = async (email: string, password: string) => {
const { error } = await supabase.auth.signUp({
email,
password,
options: { emailRedirectTo: `${window.location.origin}/profile` },
});
return { error: error?.message ?? null };
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
return { error: error?.message ?? null };
};
const signInAnonymously = async () => {
const { error } = await supabase.auth.signInAnonymously();
return { error: error?.message ?? null };
};
const signOut = async () => {
await supabase.auth.signOut();
setProfile(null);
};
const updateProfile = async (data: Partial<Profile>) => {
if (!user) return { error: "Not signed in" };
const payload: Record<string, unknown> = {
updated_at: new Date().toISOString(),
last_active: new Date().toISOString(),
};
if (data.display_name !== undefined) payload.display_name = data.display_name;
if (data.username !== undefined) payload.username = data.username;
if (data.age !== undefined) payload.age = data.age;
if (data.position !== undefined) payload.position = data.position;
if (data.bio !== undefined) payload.bio = data.bio;
if (data.kinks !== undefined) payload.kinks = data.kinks;
if (data.sti_status !== undefined) payload.sti_status = data.sti_status;
if (data.on_prep !== undefined) payload.on_prep = data.on_prep;
if (data.is_anonymous !== undefined) payload.is_anonymous = data.is_anonymous;
if (data.status !== undefined) payload.status = data.status;
if (data.city !== undefined) payload.city = data.city;
const { error } = await supabase
.from("profiles")
.update(payload)
.eq("id", user.id);
if (error) return { error: error.message };
if (data.lat != null && data.lng != null) {
await supabase.rpc("update_profile_location", {
p_user_id: user.id,
p_lat: data.lat,
p_lng: data.lng,
});
}
await fetchProfile(user.id);
return { error: null };
};
const refreshProfile = async () => {
if (user) await fetchProfile(user.id);
};
return (
<AuthContext.Provider
value={{
user,
session,
profile,
loading,
signUp,
signIn,
signInAnonymously,
signOut,
updateProfile,
refreshProfile,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

58
src/lib/cart.ts Normal file
View File

@@ -0,0 +1,58 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { CartItem } from "@/types";
interface CartStore {
items: CartItem[];
addItem: (productId: string) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
itemCount: () => number;
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
addItem: (productId) => {
const existing = get().items.find((i) => i.productId === productId);
if (existing) {
set({
items: get().items.map((i) =>
i.productId === productId
? { ...i, quantity: i.quantity + 1 }
: i
),
});
} else {
set({
items: [
...get().items,
{ productId, quantity: 1, addedAt: new Date().toISOString() },
],
});
}
},
removeItem: (productId) => {
set({ items: get().items.filter((i) => i.productId !== productId) });
},
updateQuantity: (productId, quantity) => {
if (quantity <= 0) {
get().removeItem(productId);
return;
}
set({
items: get().items.map((i) =>
i.productId === productId ? { ...i, quantity } : i
),
});
},
clearCart: () => set({ items: [] }),
itemCount: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
}),
{ name: "exposedgays-cart" }
)
);

179
src/lib/mock-data.ts Normal file
View File

@@ -0,0 +1,179 @@
import type { Profile, CruisingSpot } 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 jitter(base: number, range: number) {
return base + (Math.random() - 0.5) * range;
}
export const MOCK_PROFILES: Profile[] = [
{
id: "1",
username: "ftl_top",
display_name: "Mike",
age: 32,
position: "top",
bio: "Hosting tonight near Wilton Drive. HMU.",
avatar_url: "https://placehold.co/200x200/1a1220/3b82f6?text=Top",
status: "hosting",
is_anonymous: false,
kinks: ["oral", "rimming", "daddy"],
sti_status: "Negative · tested Jan 2026",
on_prep: true,
last_active: new Date().toISOString(),
lat: jitter(DEFAULT_LAT, 0.02),
lng: jitter(DEFAULT_LNG, 0.02),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "2",
username: null,
display_name: null,
age: 28,
position: "bottom",
bio: "At the beach. Looking now.",
avatar_url: "https://placehold.co/200x200/1a1220/f97316?text=Bot",
status: "looking",
is_anonymous: true,
kinks: ["anon", "oral", "group"],
sti_status: null,
on_prep: false,
last_active: new Date(Date.now() - 120000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.03),
lng: jitter(DEFAULT_LNG, 0.03),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "3",
username: "vers_bear",
display_name: "Jake",
age: 45,
position: "vers",
bio: "Bear. Leather friendly. Gym rat.",
avatar_url: "https://placehold.co/200x200/1a1220/a855f7?text=Vers",
status: "online",
is_anonymous: false,
kinks: ["leather", "bdsm", "oral"],
sti_status: "Negative",
on_prep: true,
last_active: new Date(Date.now() - 300000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.025),
lng: jitter(DEFAULT_LNG, 0.025),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "4",
username: "pup_play",
display_name: "Rex",
age: 26,
position: "side",
bio: "Pup scene. Woof.",
avatar_url: "https://placehold.co/200x200/1a1220/22c55e?text=Side",
status: "looking",
is_anonymous: false,
kinks: ["pup", "bondage", "roleplay"],
sti_status: null,
on_prep: true,
last_active: new Date(Date.now() - 60000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.015),
lng: jitter(DEFAULT_LNG, 0.015),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
{
id: "5",
username: "traveler_mia",
display_name: "Alex",
age: 35,
position: "vers",
bio: "Visiting from Miami. Here till Sunday.",
avatar_url: "https://placehold.co/200x200/1a1220/a855f7?text=Vers",
status: "traveling",
is_anonymous: false,
kinks: ["cruising", "anon", "oral"],
sti_status: "Negative · tested Dec 2025",
on_prep: true,
last_active: new Date(Date.now() - 180000).toISOString(),
lat: jitter(DEFAULT_LAT, 0.01),
lng: jitter(DEFAULT_LNG, 0.01),
city: "Fort Lauderdale",
vanilla_mode: false,
created_at: new Date().toISOString(),
},
];
export const MOCK_SPOTS: CruisingSpot[] = [
{
id: "s1",
name: "Hugh Taylor Birch State Park",
description: "Popular trails behind the nature center. Active after dark.",
category: "park",
lat: 26.1452,
lng: -80.1048,
city: "Fort Lauderdale",
rating: 4.2,
active_count: 8,
is_popular: true,
created_at: new Date().toISOString(),
},
{
id: "s2",
name: "Sebastian Street Beach",
description: "Gay beach section. Day and night action.",
category: "beach",
lat: 26.1175,
lng: -80.1042,
city: "Fort Lauderdale",
rating: 4.5,
active_count: 12,
is_popular: true,
created_at: new Date().toISOString(),
},
{
id: "s3",
name: "LA Fitness — Oakland Park",
description: "Locker room and sauna. Peak hours 5-8pm.",
category: "gym",
lat: 26.1723,
lng: -80.1317,
city: "Fort Lauderdale",
rating: 3.8,
active_count: 4,
is_popular: false,
created_at: new Date().toISOString(),
},
{
id: "s4",
name: "Ramrod Bar Restroom",
description: "Back bar area. Weekend nights.",
category: "restroom",
lat: 26.1603,
lng: -80.1389,
city: "Fort Lauderdale",
rating: 3.5,
active_count: 2,
is_popular: false,
created_at: new Date().toISOString(),
},
{
id: "s5",
name: "Triple Crown Bookstore",
description: "Adult bookstore with viewing booths.",
category: "bookstore",
lat: 26.1912,
lng: -80.1534,
city: "Oakland Park",
rating: 4.0,
active_count: 6,
is_popular: true,
created_at: new Date().toISOString(),
},
];

167
src/lib/products.ts Normal file
View File

@@ -0,0 +1,167 @@
import type { ShopProduct } from "@/types";
export const SHOP_PRODUCTS: ShopProduct[] = [
{
id: "pistol-pete-jock",
name: "Pistol Pete Jockstrap",
description: "Premium mesh jock with contoured pouch. Perfect for the gym or cruising.",
price: 24.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Pistol+Pete+Jock",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["underwear", "cruising"],
frequentlyBoughtWith: ["rush-poppers", "gun-oil-lube"],
},
{
id: "rush-poppers",
name: "Rush Poppers",
description: "Classic amyl nitrite formula. 10ml bottle. Ships discreet.",
price: 18.99,
category: "poppers-lube",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Rush+Poppers",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["pnp"],
frequentlyBoughtWith: ["gun-oil-lube", "condom-pack"],
},
{
id: "10in-dildo",
name: '10" Realistic Dildo',
description: "Veined silicone, suction cup base. Body-safe platinum cure silicone.",
price: 49.99,
category: "dildos-plugs",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=10in+Dildo",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["anal"],
frequentlyBoughtWith: ["gun-oil-lube", "douche-kit"],
},
{
id: "gun-oil-lube",
name: "Gun Oil Silicone Lube",
description: "Long-lasting silicone formula. 8oz pump bottle.",
price: 16.99,
category: "poppers-lube",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Gun+Oil+Lube",
affiliateUrl: "https://www.amazon.com",
affiliateLabel: "Buy on Amazon",
kinks: ["anal", "oral"],
frequentlyBoughtWith: ["condom-pack", "10in-dildo"],
},
{
id: "nasty-pig-harness",
name: "Nasty Pig Snap Harness",
description: "Adjustable leather-look harness with metal snaps. One size fits most.",
price: 89.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Nasty+Pig+Harness",
affiliateUrl: "https://www.etsy.com",
affiliateLabel: "Buy on Etsy",
kinks: ["leather", "bdsm"],
frequentlyBoughtWith: ["leather-jock", "wrist-cuffs"],
},
{
id: "bator-bros-dvd",
name: "Bator Bros Vol. 3 DVD",
description: "120 min uncut studio release. Region-free.",
price: 29.99,
category: "porn-dvds",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Bator+Bros+DVD",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["porn"],
},
{
id: "descovy-prep",
name: "Descovy PrEP (Info Kit)",
description: "Free PrEP info + telehealth referral. 99% effective when taken daily.",
price: 0,
category: "prep-health",
image: "https://placehold.co/400x400/1a1220/22c55e?text=PrEP+Info",
affiliateUrl: "https://www.prepisforme.com",
affiliateLabel: "Learn More",
kinks: [],
},
{
id: "condom-pack",
name: "Magnum XL Condom 12-Pack",
description: "Larger-fit condoms with reservoir tip. Lubricated.",
price: 12.99,
category: "prep-health",
image: "https://placehold.co/400x400/1a1220/22c55e?text=Magnum+Condoms",
affiliateUrl: "https://www.amazon.com",
affiliateLabel: "Buy on Amazon",
kinks: [],
frequentlyBoughtWith: ["gun-oil-lube"],
},
{
id: "douche-kit",
name: "Shower Shot Douche Kit",
description: "Reusable bulb + 3 nozzles. Essential cruising prep.",
price: 22.99,
category: "prep-health",
image: "https://placehold.co/400x400/1a1220/22c55e?text=Douche+Kit",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["anal", "cruising"],
frequentlyBoughtWith: ["10in-dildo", "gun-oil-lube"],
},
{
id: "leather-jock",
name: "Cellblock 13 Leather Jock",
description: "Genuine leather jockstrap with metal ring detail.",
price: 54.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Leather+Jock",
affiliateUrl: "https://www.etsy.com",
affiliateLabel: "Buy on Etsy",
kinks: ["leather", "underwear"],
},
{
id: "plug-set",
name: "3-Piece Plug Training Set",
description: "Graduated silicone plugs with flared bases. Beginner to advanced.",
price: 34.99,
category: "dildos-plugs",
image: "https://placehold.co/400x400/1a1220/ff2d6b?text=Plug+Set",
affiliateUrl: "https://www.adammale.com",
affiliateLabel: "Buy on AdamMale",
kinks: ["anal"],
frequentlyBoughtWith: ["gun-oil-lube"],
},
{
id: "wrist-cuffs",
name: "Faux Leather Wrist Cuffs",
description: "Soft padded cuffs with D-ring. Adjustable velcro.",
price: 19.99,
category: "jocks-harness",
image: "https://placehold.co/400x400/1a1220/8b2fc9?text=Wrist+Cuffs",
affiliateUrl: "https://www.amazon.com",
affiliateLabel: "Buy on Amazon",
kinks: ["bdsm", "bondage"],
},
];
export const CATEGORY_LABELS: Record<string, string> = {
"dildos-plugs": "Dildos & Plugs",
"poppers-lube": "Poppers & Lube",
"jocks-harness": "Jocks & Harness",
"porn-dvds": "Porn & DVDs",
"prep-health": "PrEP & Health",
};
export const CRUISING_BUNDLES = [
{
id: "cruising-starter",
name: "Cruising Starter Kit",
productIds: ["rush-poppers", "gun-oil-lube", "condom-pack", "douche-kit"],
savings: 8.0,
},
{
id: "leather-night",
name: "Leather Night Bundle",
productIds: ["nasty-pig-harness", "leather-jock", "wrist-cuffs", "rush-poppers"],
savings: 15.0,
},
];

View File

@@ -0,0 +1,8 @@
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

93
src/lib/supabase/hooks.ts Normal file
View File

@@ -0,0 +1,93 @@
"use client";
import { useEffect, useState } from "react";
import { createClient } from "./client";
import type { Profile, ChatMessage } from "@/types";
const supabase = createClient();
export function useNearbyProfiles(lat: number, lng: number, radiusKm = 10) {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchProfiles() {
const { data, error } = await supabase.rpc("nearby_profiles", {
p_lat: lat,
p_lng: lng,
p_radius_km: radiusKm,
});
if (!error && data) {
setProfiles(data);
}
setLoading(false);
}
fetchProfiles();
const channel = supabase
.channel("profile-changes")
.on(
"postgres_changes",
{ event: "*", schema: "public", table: "profiles" },
() => fetchProfiles()
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [lat, lng, radiusKm]);
return { profiles, loading };
}
export function useChatMessages(roomId: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
useEffect(() => {
async function fetchMessages() {
const { data } = await supabase
.from("chat_messages")
.select("*, sender:profiles(*)")
.eq("room_id", roomId)
.order("created_at", { ascending: true })
.limit(100);
if (data) setMessages(data as ChatMessage[]);
}
fetchMessages();
const channel = supabase
.channel(`room-${roomId}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "chat_messages",
filter: `room_id=eq.${roomId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new as ChatMessage]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [roomId]);
const sendMessage = async (content: string, senderId: string) => {
await supabase.from("chat_messages").insert({
room_id: roomId,
sender_id: senderId,
content,
});
};
return { messages, sendMessage };
}

View File

@@ -0,0 +1,30 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
await supabase.auth.getUser();
return supabaseResponse;
}

View File

@@ -0,0 +1,27 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Server Component — ignore
}
},
},
}
);
}

21
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,21 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatPrice(cents: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(cents);
}
export function timeAgo(date: string): string {
const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}