Integrate local guide panel with subtle Pink Pulse attribution and analytics tracking
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
272
src/components/LocalGuidePanel.tsx
Normal file
272
src/components/LocalGuidePanel.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user