diff --git a/public/partners/pinkpulse-300x250.png b/public/partners/pinkpulse-300x250.png new file mode 100644 index 0000000..89bbf63 Binary files /dev/null and b/public/partners/pinkpulse-300x250.png differ diff --git a/public/partners/pinkpulse-banner.svg b/public/partners/pinkpulse-banner.svg new file mode 100644 index 0000000..a231024 --- /dev/null +++ b/public/partners/pinkpulse-banner.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 🌈 + + + The Pink Pulse + + + LGBTQ+ Media Across 141 Cities + + + Stories • Culture • Pride • Community + + + + Subscribe Now + + + + + + + + thepinkpulse.com + \ No newline at end of file diff --git a/src/app/api/pulse-track/route.ts b/src/app/api/pulse-track/route.ts new file mode 100644 index 0000000..0d08d6d --- /dev/null +++ b/src/app/api/pulse-track/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; +import type { PulseTrackPayload } from "@/lib/pulse-analytics"; + +export const runtime = "nodejs"; + +function adminClient() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) return null; + return createClient(url, key, { auth: { persistSession: false } }); +} + +export async function POST(req: NextRequest) { + let payload: PulseTrackPayload; + try { + payload = (await req.json()) as PulseTrackPayload; + } catch { + return NextResponse.json({ ok: false, error: "invalid_json" }, { status: 400 }); + } + + const { event_type, placement } = payload; + if (!event_type || !placement) { + return NextResponse.json({ ok: false, error: "missing_fields" }, { status: 400 }); + } + + const supabase = adminClient(); + if (!supabase) { + return NextResponse.json({ ok: true, stored: false }); + } + + const ua = req.headers.get("user-agent") ?? undefined; + + const { error } = await supabase.from("pulse_referral_events").insert({ + event_type, + placement, + link_key: payload.link_key ?? null, + target_url: payload.target_url ?? null, + page_path: payload.page_path ?? null, + session_id: payload.session_id ?? null, + user_agent: ua ?? null, + }); + + if (error) { + console.error("[pulse-track]", error.message); + return NextResponse.json({ ok: true, stored: false }); + } + + return NextResponse.json({ ok: true, stored: true }); +} \ No newline at end of file diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index 97795c2..79ed0df 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -15,7 +15,7 @@ import { AuthModal } from "@/components/AuthModal"; import { PhotoGalleryManager } from "@/components/PhotoGalleryManager"; import { CannedMessagesPanel } from "@/components/CannedMessagesPanel"; import { IdVerificationModal } from "@/components/IdVerificationModal"; -import { PinkPulsePartner } from "@/components/PinkPulsePartner"; +import { LocalGuidePanel } from "@/components/LocalGuidePanel"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { useAccess } from "@/components/Providers"; import { @@ -315,7 +315,7 @@ export default function ProfilePage() { - + {message && (

diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 4508b0b..7b0dffb 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -8,7 +8,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Send, Image as ImageIcon, Users, Lock, Zap, ChevronDown, ChevronUp } from "lucide-react"; import { timeAgo } from "@/lib/utils"; import { CannedMessagesPanel } from "@/components/CannedMessagesPanel"; -import { PinkPulsePartner } from "@/components/PinkPulsePartner"; +import { LocalGuidePanel } from "@/components/LocalGuidePanel"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { useAccess } from "@/components/Providers"; @@ -156,7 +156,7 @@ export function ChatPanel({ dmUserId }: ChatPanelProps) {

Create a group room — free, no limits.

- + diff --git a/src/components/LocalGuidePanel.tsx b/src/components/LocalGuidePanel.tsx new file mode 100644 index 0000000..bc49be8 --- /dev/null +++ b/src/components/LocalGuidePanel.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { + Calendar, + ChevronDown, + ChevronUp, + ExternalLink, + HeartPulse, + MapPin, + Moon, + Newspaper, + Users, +} from "lucide-react"; +import { + LOCAL_GUIDE_SECTIONS, + PULSE_ATTRIBUTION, + partnerHomeHref, + sectionHref, + type LocalGuidePlacement, + type LocalGuideSectionKey, +} from "@/lib/local-guide"; +import { trackPulseEvent } from "@/lib/pulse-analytics"; +import { cn } from "@/lib/utils"; + +const SECTION_ICONS: Record = { + news: Newspaper, + events: Calendar, + directory: MapPin, + health: HeartPulse, + nightlife: Moon, + groups: Users, +}; + +interface LocalGuidePanelProps { + placement: LocalGuidePlacement; + variant?: "dock" | "card"; + className?: string; + defaultExpanded?: boolean; +} + +export function LocalGuidePanel({ + placement, + variant = "card", + className, + defaultExpanded = false, +}: LocalGuidePanelProps) { + const [expanded, setExpanded] = useState(defaultExpanded); + const [highlight, setHighlight] = useState(null); + const rootRef = useRef(null); + const impressionSent = useRef(false); + + useEffect(() => { + if (!rootRef.current || impressionSent.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry?.isIntersecting && !impressionSent.current) { + impressionSent.current = true; + trackPulseEvent({ event_type: "impression", placement }); + observer.disconnect(); + } + }, + { threshold: 0.35 } + ); + + observer.observe(rootRef.current); + return () => observer.disconnect(); + }, [placement]); + + const toggleExpanded = useCallback(() => { + setExpanded((prev) => { + const next = !prev; + if (next) { + trackPulseEvent({ event_type: "expand", placement }); + } + return next; + }); + }, [placement]); + + const openSection = useCallback( + (key: LocalGuideSectionKey) => { + setHighlight(key); + setExpanded(true); + trackPulseEvent({ event_type: "expand", placement, link_key: key }); + }, + [placement] + ); + + const handleOutboundClick = useCallback( + (linkKey: string, href: string) => { + trackPulseEvent({ + event_type: "click", + placement, + link_key: linkKey, + target_url: href, + }); + }, + [placement] + ); + + const isDock = variant === "dock"; + + return ( +
+
+ {/* Collapsed — local labels only */} + + + {/* Expanded — banner + browse + attribution */} +
+
+
+ + handleOutboundClick("banner", partnerHomeHref(placement)) + } + className="block relative w-full overflow-hidden rounded-lg border border-white/10 bg-[#1a003a]/30" + > + + + +

+ Browse in your area +

+ +
+ {LOCAL_GUIDE_SECTIONS.map((section) => { + const Icon = SECTION_ICONS[section.key]; + const href = sectionHref(section, placement); + const active = highlight === section.key; + return ( + handleOutboundClick(section.key, href)} + className={cn( + "rounded-lg border px-2.5 py-2.5 transition-colors group touch-manipulation min-h-[52px]", + active + ? "border-[#f92b9b]/40 bg-[#f92b9b]/10" + : "border-white/10 hover:border-[#f92b9b]/25 hover:bg-[#f92b9b]/5" + )} + > +
+ +

+ {section.localLabel} +

+
+

+ {section.localHint} +

+ + ); + })} +
+ +
+
+ +

+ {PULSE_ATTRIBUTION.poweredBy} +

+
+ + handleOutboundClick("visit_tpp", partnerHomeHref(placement)) + } + className="shrink-0 inline-flex items-center gap-1 text-[11px] font-medium text-[#f92b9b] hover:underline touch-manipulation py-1" + > + thepinkpulse.com + + +
+
+
+
+
+
+ ); +} + +/** @deprecated Use LocalGuidePanel */ +export function PinkPulsePartner({ + variant = "card", + className, +}: { + variant?: "card" | "strip" | "inline"; + className?: string; +}) { + const placement: LocalGuidePlacement = + variant === "strip" ? "map_dock" : "profile"; + return ( + + ); +} \ No newline at end of file diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 68d29a0..17c3d76 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -9,6 +9,7 @@ import { Label } from "@/components/ui/label"; 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 { KINK_OPTIONS } from "@/types"; @@ -327,15 +328,17 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) { )} - {/* Recenter */} + {/* Recenter — above local guide dock on mobile */} + +
- - - {PINK_PULSE.shortName} - {idBlur && onIdVerifyClick && (
- ); - } - - if (variant === "inline") { - return ( - - - - Community partner: {PINK_PULSE.name} - - - - ); - } - - return ( -
-
- - {PINK_PULSE.name} - -
-
- -

- Community partner -

-
- - {PINK_PULSE.name} - -

{PINK_PULSE.tagline}

-
-
- -
- {PINK_PULSE.links.map((link) => ( - -

{link.label}

-

{link.description}

- - ))} -
- - - Visit thepinkpulse.com - - -
- ); -} \ No newline at end of file diff --git a/src/components/SiteFooter.tsx b/src/components/SiteFooter.tsx index a204e52..f6d0140 100644 --- a/src/components/SiteFooter.tsx +++ b/src/components/SiteFooter.tsx @@ -3,8 +3,8 @@ import Image from "next/image"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { JTR, PINK_PULSE } from "@/lib/partners"; -import { PinkPulsePartner } from "@/components/PinkPulsePartner"; +import { JTR } from "@/lib/partners"; +import { LocalGuidePanel } from "@/components/LocalGuidePanel"; export function SiteFooter() { const pathname = usePathname(); @@ -15,8 +15,8 @@ export function SiteFooter() { return (