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 @@
+
\ 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 (
-
-
-
-
-
-
-
-
-
- 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 (