From 91c4d2c7abc1f5215dab969a483c201a8bec8243 Mon Sep 17 00:00:00 2001 From: Gendolf Date: Mon, 13 Apr 2026 18:02:45 +0000 Subject: [PATCH 1/2] fix: add copy-to-clipboard button to marketplace listing cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users were sharing dashboard URLs (which 404) instead of public links. Adds a small CopyLinkButton component (link icon → green check on copy) to GigCard, AgentCard, MCP listing cards, and Prompt listing cards so the correct public URL is one click away. Co-Authored-By: Claude Sonnet 4.6 --- src/app/mcp/page.tsx | 2 ++ src/app/prompts/page.tsx | 2 ++ src/components/agents/AgentCard.tsx | 2 ++ src/components/gigs/GigCard.tsx | 2 ++ src/components/ui/CopyLinkButton.tsx | 38 ++++++++++++++++++++++++++++ 5 files changed, 46 insertions(+) create mode 100644 src/components/ui/CopyLinkButton.tsx 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 ( + + ); +} From edca60a41f430b7b2cb091657b5963938e37daa3 Mon Sep 17 00:00:00 2001 From: Gendolf Date: Wed, 15 Apr 2026 09:47:45 +0000 Subject: [PATCH 2/2] fix(affiliates): resolve visitorId always-undefined bug with cookie-based deduplication - Read ugig_visitor cookie from incoming request; generate a UUID if absent - Pass visitorId to recordClick() instead of hardcoded undefined - Set ugig_visitor cookie (1 year) on the redirect response for new visitors - Add 24h deduplication in recordClick(): same visitorId + trackingCode within 24 hours is treated as a single click and not double-counted - IP-hash rate-limit is now a fallback used only when visitorId is unavailable Fixes #96 Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/affiliates/click/route.ts | 18 +++++++++++++++++- src/lib/affiliates/tracking.ts | 20 ++++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) 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/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")