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 { CannedMessagesPanel } from "@/components/CannedMessagesPanel";
|
||||
import { IdVerificationModal } from "@/components/IdVerificationModal";
|
||||
import { PinkPulsePartner } from "@/components/PinkPulsePartner";
|
||||
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
||||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||
import { useAccess } from "@/components/Providers";
|
||||
import {
|
||||
@@ -315,7 +315,7 @@ export default function ProfilePage() {
|
||||
|
||||
<CannedMessagesPanel />
|
||||
|
||||
<PinkPulsePartner variant="card" />
|
||||
<LocalGuidePanel placement="profile" variant="card" />
|
||||
|
||||
{message && (
|
||||
<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 { 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"
|
||||
|
||||
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