diff --git a/src/app/api/affiliates/click/route.ts b/src/app/api/affiliates/click/route.ts index adbee5f9..26715233 100644 --- a/src/app/api/affiliates/click/route.ts +++ b/src/app/api/affiliates/click/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createServiceClient } from "@/lib/supabase/service"; import { recordClick } from "@/lib/affiliates/tracking"; +import { randomUUID } from "crypto"; /** * GET /api/affiliates/click?ugig_ref=CODE - Record an affiliate click and redirect @@ -33,6 +34,10 @@ export async function GET(request: NextRequest) { return NextResponse.redirect(new URL("/affiliates", request.url)); } + // Read or generate a persistent visitor ID from cookie + const existingVisitorId = request.cookies.get("ugig_visitor")?.value; + const visitorId = existingVisitorId || randomUUID(); + // Record the click const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") @@ -40,7 +45,7 @@ export async function GET(request: NextRequest) { await recordClick(admin, { trackingCode: ref, - visitorId: undefined, // Set via cookie on client side + visitorId, ip, userAgent: request.headers.get("user-agent") || undefined, referer: request.headers.get("referer") || undefined, @@ -73,6 +78,17 @@ export async function GET(request: NextRequest) { path: "/", }); + // Persist visitor ID cookie (1 year) so repeat clicks can be deduplicated + if (!existingVisitorId) { + response.cookies.set("ugig_visitor", visitorId, { + httpOnly: true, + secure: true, + sameSite: "lax", + maxAge: 365 * 24 * 60 * 60, // 1 year + path: "/", + }); + } + return response; } catch (err) { console.error("Affiliate click error:", err); diff --git a/src/app/mcp/page.tsx b/src/app/mcp/page.tsx index 0c78bcab..7bb6d135 100644 --- a/src/app/mcp/page.tsx +++ b/src/app/mcp/page.tsx @@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Server, Star, Download, Zap } from "lucide-react"; import { MCP_CATEGORIES } from "@/lib/constants"; +import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; export const metadata: Metadata = { title: "MCP Server Marketplace | ugig.net", @@ -228,6 +229,7 @@ async function McpList({ searchParams }: { searchParams: McpPageProps["searchPar {listing.downloads_count} + diff --git a/src/app/prompts/page.tsx b/src/app/prompts/page.tsx index 20f561bc..36d8a6ad 100644 --- a/src/app/prompts/page.tsx +++ b/src/app/prompts/page.tsx @@ -9,6 +9,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { FileText, Star, Download, Zap } from "lucide-react"; import { PROMPT_CATEGORIES } from "@/lib/constants"; +import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; export const metadata: Metadata = { title: "Prompt Marketplace | ugig.net", @@ -238,6 +239,7 @@ async function PromptList({ searchParams }: { searchParams: PromptsPageProps["se {listing.downloads_count} + diff --git a/src/components/agents/AgentCard.tsx b/src/components/agents/AgentCard.tsx index ad6c0240..bc1904b3 100644 --- a/src/components/agents/AgentCard.tsx +++ b/src/components/agents/AgentCard.tsx @@ -9,6 +9,7 @@ import { ReputationBadge } from "@/components/ui/ReputationBadge"; import { MapPin, DollarSign, Coins, CheckCircle, Clock } from "lucide-react"; import { formatRelativeTime } from "@/lib/utils"; import { ZapButton } from "@/components/zaps/ZapButton"; +import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; import type { Profile } from "@/types"; interface AgentCardProps { @@ -160,6 +161,7 @@ export function AgentCard({ agent, highlightTags = [] }: AgentCardProps) { + diff --git a/src/components/gigs/GigCard.tsx b/src/components/gigs/GigCard.tsx index f0f21006..32e13633 100644 --- a/src/components/gigs/GigCard.tsx +++ b/src/components/gigs/GigCard.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { AgentBadge } from "@/components/ui/AgentBadge"; import { VerifiedBadge } from "@/components/ui/VerifiedBadge"; import { SaveGigButton } from "./SaveGigButton"; +import { CopyLinkButton } from "@/components/ui/CopyLinkButton"; import { formatCurrency, formatRelativeTime } from "@/lib/utils"; import { linkifyText } from "@/lib/linkify"; import type { Gig, Profile } from "@/types"; @@ -103,6 +104,7 @@ export function GigCard({ {poster?.account_type === "agent" && ( )} + {showSaveButton && ( { + e.preventDefault(); + e.stopPropagation(); + const url = `${window.location.origin}${path}`; + navigator.clipboard.writeText(url).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + return ( + + ); +} diff --git a/src/lib/affiliates/tracking.ts b/src/lib/affiliates/tracking.ts index f6a6792c..3b95202c 100644 --- a/src/lib/affiliates/tracking.ts +++ b/src/lib/affiliates/tracking.ts @@ -55,8 +55,24 @@ export async function recordClick( const ipHash = ip ? hashIP(ip) : null; - // Rate limit: max 1 click per visitor per offer per hour - if (ipHash) { + // Deduplicate by visitorId + trackingCode within 24 hours + if (visitorId) { + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const { count } = await (admin as AnySupabase) + .from("affiliate_clicks") + .select("id", { count: "exact", head: true }) + .eq("tracking_code", trackingCode) + .eq("visitor_id", visitorId) + .gte("created_at", oneDayAgo); + + if ((count ?? 0) > 0) { + // Deduplicated — same visitor already clicked this link within 24h + return { ok: true, offer_id: app.offer_id }; + } + } + + // Rate limit: max 1 click per IP per offer per hour (fallback when no visitorId) + if (ipHash && !visitorId) { const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); const { count } = await (admin as AnySupabase) .from("affiliate_clicks")