Integrate local guide panel with subtle Pink Pulse attribution and analytics tracking
This commit is contained in:
BIN
public/partners/pinkpulse-300x250.png
Normal file
BIN
public/partners/pinkpulse-300x250.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
58
public/partners/pinkpulse-banner.svg
Normal file
58
public/partners/pinkpulse-banner.svg
Normal 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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:"Anthropic Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 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 |
50
src/app/api/pulse-track/route.ts
Normal file
50
src/app/api/pulse-track/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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"}`}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
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 { 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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 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
105
src/lib/local-guide.ts
Normal 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");
|
||||||
|
}
|
||||||
70
src/lib/pulse-analytics.ts
Normal file
70
src/lib/pulse-analytics.ts
Normal 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 */
|
||||||
|
});
|
||||||
|
}
|
||||||
22
supabase/migration_pulse_analytics.sql
Normal file
22
supabase/migration_pulse_analytics.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user