Integrate local guide panel with subtle Pink Pulse attribution and analytics tracking

This commit is contained in:
Ryan Salazar
2026-06-26 21:50:46 -04:00
parent 37ec773431
commit 76834b3d21
13 changed files with 592 additions and 192 deletions

View File

@@ -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) {
<p className="text-xs text-muted-foreground text-center pt-2">
Create a group room free, no limits.
</p>
<PinkPulsePartner variant="card" className="mt-4" />
<LocalGuidePanel placement="chat_groups" variant="card" className="mt-4" />
</div>
</TabsContent>

View File

@@ -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<LocalGuideSectionKey, typeof Newspaper> = {
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<LocalGuideSectionKey | null>(null);
const rootRef = useRef<HTMLDivElement>(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 (
<div
ref={rootRef}
className={cn(
isDock
? "fixed left-0 right-0 z-[998] bottom-16 md:bottom-4 md:left-auto md:right-4 md:max-w-sm pointer-events-none"
: "w-full",
className
)}
>
<div
className={cn(
"pointer-events-auto border border-white/10 bg-[#0a1628]/96 backdrop-blur-md shadow-2xl overflow-hidden",
isDock ? "rounded-t-2xl md:rounded-2xl" : "rounded-xl"
)}
>
{/* Collapsed — local labels only */}
<button
type="button"
onClick={toggleExpanded}
className="w-full flex items-center gap-2 px-3 py-2.5 sm:py-3 touch-manipulation active:bg-white/5 transition-colors"
aria-expanded={expanded}
>
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-blue-300/90">
Local
</span>
<div className="flex-1 min-w-0 overflow-x-auto scrollbar-none">
<div className="flex gap-1.5 w-max pr-1">
{LOCAL_GUIDE_SECTIONS.map((section) => (
<span
key={section.key}
role="presentation"
onClick={(e) => {
e.stopPropagation();
openSection(section.key);
}}
className="shrink-0 text-[11px] sm:text-xs px-2.5 py-1 rounded-full bg-white/5 border border-white/10 text-foreground/90 hover:bg-white/10 hover:border-blue-400/30 transition-colors"
>
{section.localLabel.replace("Local ", "")}
</span>
))}
</div>
</div>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
</button>
{/* Expanded — banner + browse + attribution */}
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
expanded ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="px-3 pb-3 pt-0 space-y-3 border-t border-white/10">
<Link
href={partnerHomeHref(placement)}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
handleOutboundClick("banner", partnerHomeHref(placement))
}
className="block relative w-full overflow-hidden rounded-lg border border-white/10 bg-[#1a003a]/30"
>
<Image
src={PULSE_ATTRIBUTION.bannerRect}
alt=""
width={300}
height={250}
className="w-full h-auto object-cover"
priority={isDock}
/>
</Link>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground px-0.5">
Browse in your area
</p>
<div className="grid grid-cols-2 gap-1.5">
{LOCAL_GUIDE_SECTIONS.map((section) => {
const Icon = SECTION_ICONS[section.key];
const href = sectionHref(section, placement);
const active = highlight === section.key;
return (
<Link
key={section.key}
href={href}
target="_blank"
rel="noopener noreferrer"
onClick={() => 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"
)}
>
<div className="flex items-center gap-1.5 mb-0.5">
<Icon className="h-3 w-3 text-blue-300/80 group-hover:text-[#f92b9b]" />
<p className="text-xs font-medium leading-tight">
{section.localLabel}
</p>
</div>
<p className="text-[10px] text-muted-foreground leading-snug">
{section.localHint}
</p>
</Link>
);
})}
</div>
<div className="flex items-center justify-between gap-2 pt-1 border-t border-white/10">
<div className="flex items-center gap-2 min-w-0">
<Image
src={PULSE_ATTRIBUTION.icon}
alt=""
width={20}
height={20}
className="rounded shrink-0"
/>
<p className="text-[10px] text-muted-foreground truncate">
{PULSE_ATTRIBUTION.poweredBy}
</p>
</div>
<Link
href={partnerHomeHref(placement)}
target="_blank"
rel="noopener noreferrer"
onClick={() =>
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
<ExternalLink className="h-3 w-3 opacity-70" />
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
/** @deprecated Use LocalGuidePanel */
export function PinkPulsePartner({
variant = "card",
className,
}: {
variant?: "card" | "strip" | "inline";
className?: string;
}) {
const placement: LocalGuidePlacement =
variant === "strip" ? "map_dock" : "profile";
return (
<LocalGuidePanel
placement={placement}
variant={variant === "strip" ? "dock" : "card"}
className={className}
/>
);
}

View File

@@ -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) {
</MapContainer>
)}
{/* Recenter */}
{/* Recenter — above local guide dock on mobile */}
<button
type="button"
className="absolute bottom-20 right-3 z-[1000] p-3 rounded-full bg-[#0a1628]/90 border border-white/15 text-blue-400 shadow-lg touch-manipulation md:bottom-6"
className="absolute bottom-32 right-3 z-[1000] p-3 rounded-full bg-[#0a1628]/90 border border-white/15 text-blue-400 shadow-lg touch-manipulation md:bottom-6"
aria-label="Recenter map"
>
<Locate className="h-5 w-5" />
</button>
<LocalGuidePanel placement="map_dock" variant="dock" />
<CruiserProfileView
profile={selectedProfile}
open={!!selectedProfile}

View File

@@ -3,8 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut, ShieldCheck, Sparkles } from "lucide-react";
import { PINK_PULSE } from "@/lib/partners";
import { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Logo } from "@/components/Logo";
@@ -79,16 +78,6 @@ export function Nav({
</nav>
<div className="flex items-center gap-2">
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="hidden lg:flex items-center gap-1.5 text-xs text-muted-foreground hover:text-[#f92b9b] transition-colors px-2 py-1 rounded-md border border-transparent hover:border-[#f92b9b]/20"
title={`Community partner: ${PINK_PULSE.name}`}
>
<Sparkles className="h-3.5 w-3.5" />
<span className="font-medium">{PINK_PULSE.shortName}</span>
</Link>
{idBlur && onIdVerifyClick && (
<Button
variant="outline"

View File

@@ -1,145 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { ExternalLink, Sparkles } from "lucide-react";
import { PINK_PULSE } from "@/lib/partners";
import { cn } from "@/lib/utils";
interface PinkPulsePartnerProps {
variant?: "card" | "strip" | "inline";
className?: string;
}
export function PinkPulsePartner({ variant = "card", className }: PinkPulsePartnerProps) {
if (variant === "strip") {
return (
<div
className={cn(
"flex items-center gap-3 overflow-x-auto px-3 py-2 border-t border-border/60 bg-horny-surface/90 backdrop-blur-sm",
className
)}
>
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 shrink-0 pr-2 border-r border-border/50"
>
<Image
src={PINK_PULSE.logo}
alt={PINK_PULSE.name}
width={28}
height={28}
className="rounded-lg"
/>
<div className="leading-tight">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">
Community partner
</p>
<p className="text-xs font-semibold text-[#f92b9b]">{PINK_PULSE.shortName}</p>
</div>
</Link>
{PINK_PULSE.links.slice(0, 5).map((link) => (
<Link
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-xs px-3 py-1.5 rounded-full border border-[#f92b9b]/25 text-foreground/90 hover:bg-[#f92b9b]/10 hover:border-[#f92b9b]/40 transition-colors touch-manipulation"
>
{link.label}
</Link>
))}
</div>
);
}
if (variant === "inline") {
return (
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"inline-flex items-center gap-2 text-xs text-muted-foreground hover:text-[#f92b9b] transition-colors",
className
)}
>
<Image src={PINK_PULSE.logo} alt="" width={18} height={18} className="rounded" />
<span>
Community partner: <span className="font-medium text-[#f92b9b]">{PINK_PULSE.name}</span>
</span>
<ExternalLink className="h-3 w-3 opacity-60" />
</Link>
);
}
return (
<div
className={cn(
"rounded-xl border border-[#f92b9b]/20 bg-gradient-to-br from-[#1a003a]/40 to-horny-surface p-3 space-y-3",
className
)}
>
<div className="flex items-start gap-3">
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="shrink-0"
>
<Image
src={PINK_PULSE.logo}
alt={PINK_PULSE.name}
width={40}
height={40}
className="rounded-xl"
/>
</Link>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<Sparkles className="h-3 w-3 text-[#f92b9b]" />
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
Community partner
</p>
</div>
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-semibold text-[#f92b9b] hover:underline"
>
{PINK_PULSE.name}
</Link>
<p className="text-xs text-muted-foreground mt-0.5">{PINK_PULSE.tagline}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-1.5">
{PINK_PULSE.links.map((link) => (
<Link
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-border/60 px-2.5 py-2 hover:border-[#f92b9b]/30 hover:bg-[#f92b9b]/5 transition-colors group"
>
<p className="text-xs font-medium group-hover:text-[#f92b9b]">{link.label}</p>
<p className="text-[10px] text-muted-foreground truncate">{link.description}</p>
</Link>
))}
</div>
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 text-xs text-[#f92b9b] font-medium hover:underline"
>
Visit thepinkpulse.com
<ExternalLink className="h-3 w-3" />
</Link>
</div>
);
}

View File

@@ -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 (
<footer className="border-t border-border bg-horny-dark/80 mt-auto">
<div className="mx-auto max-w-5xl px-4 py-6 space-y-5">
<div className="hidden sm:block max-w-sm">
<PinkPulsePartner variant="card" />
<div className="max-w-md">
<LocalGuidePanel placement="footer" variant="card" />
</div>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
@@ -41,16 +41,7 @@ export function SiteFooter() {
</Link>
<p className="text-xs text-muted-foreground max-w-sm">
ExposedGays is a free adult platform from the Just Two Roommates media
family alongside{" "}
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="text-[#f92b9b] hover:underline"
>
{PINK_PULSE.name}
</Link>
, your queer city guide.
family with local events, directory, and news built into your map.
</p>
</div>
@@ -61,21 +52,6 @@ export function SiteFooter() {
<Link href="/privacy" className="hover:text-foreground">
Privacy
</Link>
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#f92b9b] inline-flex items-center gap-1"
>
<Image
src={PINK_PULSE.logo}
alt=""
width={14}
height={14}
className="rounded-sm"
/>
{PINK_PULSE.shortName}
</Link>
<Link
href={JTR.url}
target="_blank"