ExposedGays: map, shop, auth, Supabase schema, Coolify Dockerfile
This commit is contained in:
189
src/lib/auth/context.tsx
Normal file
189
src/lib/auth/context.tsx
Normal 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
58
src/lib/cart.ts
Normal 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
179
src/lib/mock-data.ts
Normal 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
167
src/lib/products.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
8
src/lib/supabase/client.ts
Normal file
8
src/lib/supabase/client.ts
Normal 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
93
src/lib/supabase/hooks.ts
Normal 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 };
|
||||
}
|
||||
30
src/lib/supabase/middleware.ts
Normal file
30
src/lib/supabase/middleware.ts
Normal 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;
|
||||
}
|
||||
27
src/lib/supabase/server.ts
Normal file
27
src/lib/supabase/server.ts
Normal 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
21
src/lib/utils.ts
Normal 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`;
|
||||
}
|
||||
Reference in New Issue
Block a user