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

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

View File

@@ -0,0 +1,58 @@
<svg width="100%" viewBox="0 0 2670 1450" xmlns="http://www.w3.org/2000/svg" style="">
<defs>
<linearGradient id="purpleGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#9d4edd;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#5a189a;stop-opacity:1"/>
</linearGradient>
<linearGradient id="rainbowGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#ff0000;stop-opacity:1"/>
<stop offset="16%" style="stop-color:#ff7f00;stop-opacity:1"/>
<stop offset="33%" style="stop-color:#ffff00;stop-opacity:1"/>
<stop offset="50%" style="stop-color:#00ff00;stop-opacity:1"/>
<stop offset="66%" style="stop-color:#0000ff;stop-opacity:1"/>
<stop offset="83%" style="stop-color:#4b0082;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#9400d3;stop-opacity:1"/>
</linearGradient>
</defs>
<!-- Background -->
<rect width="2560" height="1440" fill="#0a0e27" style="fill:rgb(10, 14, 39);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Gradient overlay -->
<rect width="2560" height="1440" fill="url(#purpleGrad)" opacity="0.4" style="stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.4;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Decorative circles -->
<circle cx="200" cy="300" r="250" fill="#ff006e" opacity="0.1" style="fill:rgb(255, 0, 110);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="2360" cy="1100" r="300" fill="#00d4ff" opacity="0.1" style="fill:rgb(0, 212, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<circle cx="1280" cy="700" r="200" fill="#9d4edd" opacity="0.08" style="fill:rgb(157, 78, 221);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.08;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Rainbow stripe -->
<rect x="0" y="0" width="2560" height="12" fill="url(#rainbowGrad)" style="stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<!-- Main content area -->
<g style="fill:rgb(0, 0, 0);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto">
<!-- Logo/Icon placeholder -->
<circle cx="400" cy="720" r="150" fill="url(#rainbowGrad)" opacity="0.9" style="stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:0.9;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<text x="400" y="750" font-size="120" font-weight="bold" text-anchor="middle" fill="white" font-family="Arial, sans-serif" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Arial, sans-serif;font-size:120px;font-weight:700;text-anchor:middle;dominant-baseline:auto">🌈</text>
<!-- Main headline -->
<text x="800" y="500" font-size="180" font-weight="bold" fill="white" font-family="Arial, sans-serif" letter-spacing="2" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Arial, sans-serif;font-size:180px;font-weight:700;text-anchor:start;dominant-baseline:auto">The Pink Pulse</text>
<!-- Tagline -->
<text x="800" y="680" font-size="72" fill="#ff006e" font-family="Arial, sans-serif" font-weight="500" letter-spacing="1" style="fill:rgb(255, 0, 110);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Arial, sans-serif;font-size:72px;font-weight:500;text-anchor:start;dominant-baseline:auto">LGBTQ+ Media Across 141 Cities</text>
<!-- Descriptive text -->
<text x="800" y="870" font-size="52" fill="#b0b0b0" font-family="Arial, sans-serif" style="fill:rgb(176, 176, 176);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Arial, sans-serif;font-size:52px;font-weight:400;text-anchor:start;dominant-baseline:auto">Stories • Culture • Pride • Community</text>
<!-- CTA -->
<rect x="800" y="1000" width="600" height="120" fill="url(#rainbowGrad)" rx="20" style="stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<text x="1100" y="1080" font-size="64" font-weight="bold" fill="white" text-anchor="middle" font-family="Arial, sans-serif" style="fill:rgb(255, 255, 255);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Arial, sans-serif;font-size:64px;font-weight:700;text-anchor:middle;dominant-baseline:auto">Subscribe Now</text>
<!-- Accent line -->
<line x1="800" y1="950" x2="1400" y2="950" stroke="url(#rainbowGrad)" stroke-width="6" stroke-linecap="round" style="fill:rgb(0, 0, 0);color:rgb(0, 0, 0);stroke-width:6px;stroke-linecap:round;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
</g>
<!-- Bottom branding -->
<rect x="0" y="1428" width="2560" height="12" fill="url(#rainbowGrad)" style="stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:&quot;Anthropic Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, sans-serif;font-size:16px;font-weight:400;text-anchor:start;dominant-baseline:auto"/>
<text x="1280" y="1410" font-size="40" fill="#888888" text-anchor="middle" font-family="Arial, sans-serif" style="fill:rgb(136, 136, 136);stroke:none;color:rgb(0, 0, 0);stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;opacity:1;font-family:Arial, sans-serif;font-size:40px;font-weight:400;text-anchor:middle;dominant-baseline:auto">thepinkpulse.com</text>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@@ -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 });
}

View File

@@ -15,7 +15,7 @@ import { AuthModal } from "@/components/AuthModal";
import { PhotoGalleryManager } from "@/components/PhotoGalleryManager"; import { PhotoGalleryManager } from "@/components/PhotoGalleryManager";
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel"; import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
import { IdVerificationModal } from "@/components/IdVerificationModal"; import { IdVerificationModal } from "@/components/IdVerificationModal";
import { PinkPulsePartner } from "@/components/PinkPulsePartner"; import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { useAccess } from "@/components/Providers"; import { useAccess } from "@/components/Providers";
import { import {
@@ -315,7 +315,7 @@ export default function ProfilePage() {
<CannedMessagesPanel /> <CannedMessagesPanel />
<PinkPulsePartner variant="card" /> <LocalGuidePanel placement="profile" variant="card" />
{message && ( {message && (
<p className={`text-sm text-center ${message.startsWith("Error") ? "text-destructive" : "text-green-400"}`}> <p className={`text-sm text-center ${message.startsWith("Error") ? "text-destructive" : "text-green-400"}`}>

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 { Send, Image as ImageIcon, Users, Lock, Zap, ChevronDown, ChevronUp } from "lucide-react";
import { timeAgo } from "@/lib/utils"; import { timeAgo } from "@/lib/utils";
import { CannedMessagesPanel } from "@/components/CannedMessagesPanel"; import { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
import { PinkPulsePartner } from "@/components/PinkPulsePartner"; import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { useAccess } from "@/components/Providers"; 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"> <p className="text-xs text-muted-foreground text-center pt-2">
Create a group room free, no limits. Create a group room free, no limits.
</p> </p>
<PinkPulsePartner variant="card" className="mt-4" /> <LocalGuidePanel placement="chat_groups" variant="card" className="mt-4" />
</div> </div>
</TabsContent> </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 { Slider } from "@/components/ui/slider";
import { CruiserProfileView } from "@/components/CruiserProfileView"; import { CruiserProfileView } from "@/components/CruiserProfileView";
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner"; import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
import { Filter, X, Locate } from "lucide-react"; import { Filter, X, Locate } from "lucide-react";
import type { Profile, CruisingSpot, MapFilters, Position } from "@/types"; import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
import { KINK_OPTIONS } from "@/types"; import { KINK_OPTIONS } from "@/types";
@@ -327,15 +328,17 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
</MapContainer> </MapContainer>
)} )}
{/* Recenter */} {/* Recenter — above local guide dock on mobile */}
<button <button
type="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" aria-label="Recenter map"
> >
<Locate className="h-5 w-5" /> <Locate className="h-5 w-5" />
</button> </button>
<LocalGuidePanel placement="map_dock" variant="dock" />
<CruiserProfileView <CruiserProfileView
profile={selectedProfile} profile={selectedProfile}
open={!!selectedProfile} open={!!selectedProfile}

View File

@@ -3,8 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut, ShieldCheck, Sparkles } from "lucide-react"; import { Map, MessageCircle, MapPin, ShoppingBag, User, Eye, EyeOff, LogIn, LogOut, ShieldCheck } from "lucide-react";
import { PINK_PULSE } from "@/lib/partners";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
@@ -79,16 +78,6 @@ export function Nav({
</nav> </nav>
<div className="flex items-center gap-2"> <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 && ( {idBlur && onIdVerifyClick && (
<Button <Button
variant="outline" 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 Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { JTR, PINK_PULSE } from "@/lib/partners"; import { JTR } from "@/lib/partners";
import { PinkPulsePartner } from "@/components/PinkPulsePartner"; import { LocalGuidePanel } from "@/components/LocalGuidePanel";
export function SiteFooter() { export function SiteFooter() {
const pathname = usePathname(); const pathname = usePathname();
@@ -15,8 +15,8 @@ export function SiteFooter() {
return ( return (
<footer className="border-t border-border bg-horny-dark/80 mt-auto"> <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="mx-auto max-w-5xl px-4 py-6 space-y-5">
<div className="hidden sm:block max-w-sm"> <div className="max-w-md">
<PinkPulsePartner variant="card" /> <LocalGuidePanel placement="footer" variant="card" />
</div> </div>
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4">
@@ -41,16 +41,7 @@ export function SiteFooter() {
</Link> </Link>
<p className="text-xs text-muted-foreground max-w-sm"> <p className="text-xs text-muted-foreground max-w-sm">
ExposedGays is a free adult platform from the Just Two Roommates media ExposedGays is a free adult platform from the Just Two Roommates media
family alongside{" "} family with local events, directory, and news built into your map.
<Link
href={PINK_PULSE.url}
target="_blank"
rel="noopener noreferrer"
className="text-[#f92b9b] hover:underline"
>
{PINK_PULSE.name}
</Link>
, your queer city guide.
</p> </p>
</div> </div>
@@ -61,21 +52,6 @@ export function SiteFooter() {
<Link href="/privacy" className="hover:text-foreground"> <Link href="/privacy" className="hover:text-foreground">
Privacy Privacy
</Link> </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 <Link
href={JTR.url} href={JTR.url}
target="_blank" target="_blank"

105
src/lib/local-guide.ts Normal file
View File

@@ -0,0 +1,105 @@
/** Local-facing guide labels — Pink Pulse attribution only on expand. */
export type LocalGuidePlacement =
| "map_dock"
| "profile"
| "chat_groups"
| "footer"
| "nav_desktop";
export type LocalGuideSectionKey =
| "news"
| "events"
| "directory"
| "health"
| "nightlife"
| "groups";
export interface LocalGuideSection {
key: LocalGuideSectionKey;
localLabel: string;
localHint: string;
href: string;
}
const UTM_BASE = {
utm_source: "exposedgays",
utm_medium: "local_guide",
utm_campaign: "jtr_partner",
} as const;
export function withPulseUtm(
href: string,
placement: LocalGuidePlacement,
linkKey: string
): string {
try {
const url = new URL(href);
url.searchParams.set("utm_source", UTM_BASE.utm_source);
url.searchParams.set("utm_medium", UTM_BASE.utm_medium);
url.searchParams.set("utm_campaign", UTM_BASE.utm_campaign);
url.searchParams.set("utm_content", `${placement}_${linkKey}`);
return url.toString();
} catch {
return href;
}
}
export const PULSE_ATTRIBUTION = {
poweredBy: "Powered by The Pink Pulse",
partnerName: "The Pink Pulse",
partnerUrl: "https://thepinkpulse.com",
bannerWide: "/partners/pinkpulse-banner.svg",
bannerRect: "/partners/pinkpulse-300x250.png",
icon: "/partners/pinkpulse-icon.svg",
} as const;
export const LOCAL_GUIDE_SECTIONS: LocalGuideSection[] = [
{
key: "news",
localLabel: "Local News",
localHint: "Queer headlines near you",
href: "https://thepinkpulse.com/the-latest",
},
{
key: "events",
localLabel: "Local Events",
localHint: "This weekend around town",
href: "https://thepinkpulse.com/events-calendar",
},
{
key: "directory",
localLabel: "Local Directory",
localHint: "Shops, bars & services",
href: "https://thepinkpulse.com/directory",
},
{
key: "health",
localLabel: "Local Health",
localHint: "Testing, PrEP & wellness",
href: "https://thepinkpulse.com/health-wellness",
},
{
key: "nightlife",
localLabel: "Local Nightlife",
localHint: "Bars, drag & going out",
href: "https://thepinkpulse.com/nightlife",
},
{
key: "groups",
localLabel: "Local Groups",
localHint: "Meetups & community",
href: "https://thepinkpulse.com/groups",
},
];
export function sectionHref(
section: LocalGuideSection,
placement: LocalGuidePlacement
): string {
return withPulseUtm(section.href, placement, section.key);
}
export function partnerHomeHref(placement: LocalGuidePlacement): string {
return withPulseUtm(PULSE_ATTRIBUTION.partnerUrl, placement, "home");
}

View File

@@ -0,0 +1,70 @@
import type { LocalGuidePlacement } from "@/lib/local-guide";
export type PulseEventType = "impression" | "click" | "expand";
export interface PulseTrackPayload {
event_type: PulseEventType;
placement: LocalGuidePlacement;
link_key?: string;
target_url?: string;
page_path?: string;
session_id?: string;
}
const SESSION_KEY = "eg_pulse_sid";
const DEDUPE_PREFIX = "eg_pulse_seen_";
function getSessionId(): string {
if (typeof window === "undefined") return "server";
try {
let sid = sessionStorage.getItem(SESSION_KEY);
if (!sid) {
sid =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `eg-${Date.now()}-${Math.random().toString(36).slice(2)}`;
sessionStorage.setItem(SESSION_KEY, sid);
}
return sid;
} catch {
return `eg-fallback-${Date.now()}`;
}
}
function dedupeKey(payload: PulseTrackPayload): string {
return `${DEDUPE_PREFIX}${payload.event_type}_${payload.placement}_${payload.link_key ?? "panel"}`;
}
export function trackPulseEvent(payload: PulseTrackPayload): void {
if (typeof window === "undefined") return;
if (payload.event_type === "impression") {
try {
if (sessionStorage.getItem(dedupeKey(payload))) return;
sessionStorage.setItem(dedupeKey(payload), "1");
} catch {
/* ignore */
}
}
const body: PulseTrackPayload = {
...payload,
session_id: getSessionId(),
page_path: payload.page_path ?? window.location.pathname,
};
const blob = new Blob([JSON.stringify(body)], { type: "application/json" });
if (typeof navigator !== "undefined" && "sendBeacon" in navigator) {
navigator.sendBeacon("/api/pulse-track", blob);
return;
}
fetch("/api/pulse-track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
keepalive: true,
}).catch(() => {
/* non-blocking */
});
}

View File

@@ -0,0 +1,22 @@
-- Pink Pulse referral analytics (ExposedGays local guide)
-- Run on SUPABASE_01 in exposedgays public schema
CREATE TABLE IF NOT EXISTS public.pulse_referral_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_type TEXT NOT NULL CHECK (event_type IN ('impression', 'click', 'expand')),
placement TEXT NOT NULL,
link_key TEXT,
target_url TEXT,
page_path TEXT,
session_id TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_pulse_events_created
ON public.pulse_referral_events (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pulse_events_placement
ON public.pulse_referral_events (placement, event_type);
ALTER TABLE public.pulse_referral_events ENABLE ROW LEVEL SECURITY;