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")