Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/app/api/affiliates/click/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -33,14 +34,18 @@ 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")
|| "unknown";

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,
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/app/mcp/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -228,6 +229,7 @@ async function McpList({ searchParams }: { searchParams: McpPageProps["searchPar
<Download className="h-3.5 w-3.5" />
{listing.downloads_count}
</span>
<CopyLinkButton path={`/mcp/${listing.slug}`} />
</div>
</div>
</Link>
Expand Down
2 changes: 2 additions & 0 deletions src/app/prompts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -238,6 +239,7 @@ async function PromptList({ searchParams }: { searchParams: PromptsPageProps["se
<Download className="h-3.5 w-3.5" />
{listing.downloads_count}
</span>
<CopyLinkButton path={`/prompts/${listing.slug}`} />
</div>
</div>
</Link>
Expand Down
2 changes: 2 additions & 0 deletions src/components/agents/AgentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -160,6 +161,7 @@ export function AgentCard({ agent, highlightTags = [] }: AgentCardProps) {
<Button size="sm">Hire Agent</Button>
</Link>
<ZapButton targetType="profile" targetId={agent.id} recipientId={agent.id} />
<CopyLinkButton path={`/u/${agent.username}`} />
</div>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/gigs/GigCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,6 +104,7 @@ export function GigCard({
{poster?.account_type === "agent" && (
<AgentBadge size="sm" />
)}
<CopyLinkButton path={detailHref} />
{showSaveButton && (
<SaveGigButton
gigId={gig.id}
Expand Down
38 changes: 38 additions & 0 deletions src/components/ui/CopyLinkButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import { useState } from "react";
import { Link, Check } from "lucide-react";

interface CopyLinkButtonProps {
path: string;
className?: string;
}

export function CopyLinkButton({ path, className = "" }: CopyLinkButtonProps) {
const [copied, setCopied] = useState(false);

const handleCopy = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const url = `${window.location.origin}${path}`;
navigator.clipboard.writeText(url).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};

return (
<button
type="button"
onClick={handleCopy}
title={copied ? "Copied!" : "Copy shareable link"}
className={`inline-flex items-center justify-center h-7 w-7 rounded-md text-muted-foreground hover:text-primary hover:bg-muted transition-colors ${className}`}
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Link className="h-3.5 w-3.5" />
)}
</button>
);
}
20 changes: 18 additions & 2 deletions src/lib/affiliates/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down