diff --git a/src/app/api/gigs/[id]/boost/route.ts b/src/app/api/gigs/[id]/boost/route.ts new file mode 100644 index 00000000..b6c88e2e --- /dev/null +++ b/src/app/api/gigs/[id]/boost/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthContext } from "@/lib/auth/get-user"; +import { checkRateLimit, rateLimitExceeded, getRateLimitIdentifier } from "@/lib/rate-limit"; +import { getBoostEligibility, BOOST_COOLDOWN_DAYS } from "@/lib/boost"; +import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; + +// POST /api/gigs/[id]/boost - Bump an active gig back to the top of the listing. +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const auth = await getAuthContext(request); + if (!auth) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const { user, supabase } = auth; + + const rl = checkRateLimit(getRateLimitIdentifier(request, user.id), "write"); + if (!rl.allowed) return rateLimitExceeded(rl); + + const { data: existingGig } = await supabase + .from("gigs") + .select("poster_id, status, created_at, boosted_at") + .eq("id", id) + .single(); + + if (!existingGig) { + return NextResponse.json({ error: "Gig not found" }, { status: 404 }); + } + + if (existingGig.poster_id !== user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Only active gigs appear in the public listing, so only they can be boosted. + if (existingGig.status !== "active") { + return NextResponse.json( + { error: "Only active gigs can be boosted." }, + { status: 400 } + ); + } + + const eligibility = getBoostEligibility(existingGig); + if (!eligibility.eligible) { + return NextResponse.json( + { + error: `Gigs can be boosted once every ${BOOST_COOLDOWN_DAYS} days. Try again later.`, + nextEligibleAt: eligibility.nextEligibleAt, + }, + { status: 429 } + ); + } + + const boostedAt = new Date().toISOString(); + const { data: gig, error } = await supabase + .from("gigs") + .update({ boosted_at: boostedAt, updated_at: boostedAt }) + .eq("id", id) + .select() + .single(); + + if (error) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + + dispatchWebhookAsync(user.id, "gig.update", { + gig_id: id, + boosted_at: boostedAt, + }); + + return NextResponse.json({ gig }); + } catch { + return NextResponse.json( + { error: "An unexpected error occurred" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/gigs/route.ts b/src/app/api/gigs/route.ts index cd218842..2beacd8a 100644 --- a/src/app/api/gigs/route.ts +++ b/src/app/api/gigs/route.ts @@ -135,8 +135,8 @@ export async function GET(request: NextRequest) { case "budget_low": query = query.order("budget_min", { ascending: true, nullsFirst: false }); break; - default: // newest - query = query.order("created_at", { ascending: false }); + default: // newest — boosted gigs rank by their boost time (ranked_at), else creation time + query = query.order("ranked_at", { ascending: false }); } // Apply pagination — ensure non-negative offset (#69) diff --git a/src/app/dashboard/gigs/page.tsx b/src/app/dashboard/gigs/page.tsx index 9bfe41dc..7ca66276 100644 --- a/src/app/dashboard/gigs/page.tsx +++ b/src/app/dashboard/gigs/page.tsx @@ -181,7 +181,12 @@ export default async function MyGigsPage({ searchParams }: MyGigsPageProps) { - + {(pendingByGig[gig.id]?.length ?? 0) > 0 && ( diff --git a/src/components/gigs/GigActions.tsx b/src/components/gigs/GigActions.tsx index 39cb0a7e..529d3adc 100644 --- a/src/components/gigs/GigActions.tsx +++ b/src/components/gigs/GigActions.tsx @@ -13,22 +13,28 @@ import { CheckCircle, Loader2, XCircle, + Rocket, } from "lucide-react"; import Link from "next/link"; import { useDialog } from "@/components/providers/DialogProvider"; +import { getBoostEligibility } from "@/lib/boost"; interface GigActionsProps { gigId: string; status: string; + createdAt?: string | null; + boostedAt?: string | null; } -export function GigActions({ gigId, status }: GigActionsProps) { +export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsProps) { const router = useRouter(); const { confirm } = useDialog(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const boost = getBoostEligibility({ created_at: createdAt, boosted_at: boostedAt }); + const handleStatusChange = async (newStatus: string) => { setIsLoading(true); setError(null); @@ -49,6 +55,23 @@ export function GigActions({ gigId, status }: GigActionsProps) { router.refresh(); }; + const handleBoost = async () => { + setIsLoading(true); + setError(null); + + const result = await gigsApi.boost(gigId); + + if (result.error) { + setError(result.error); + setIsLoading(false); + return; + } + + setIsOpen(false); + setIsLoading(false); + router.refresh(); + }; + const handleDelete = async () => { if (!await confirm("Are you sure you want to delete this gig? This action cannot be undone.")) { return; @@ -118,6 +141,26 @@ export function GigActions({ gigId, status }: GigActionsProps) { {status === "active" && ( <> + {boost.eligible ? ( + + ) : ( +
+ + + Boost available{" "} + {boost.nextEligibleAt + ? new Date(boost.nextEligibleAt).toLocaleDateString() + : "soon"} + +
+ )}