From f2c1a422edad14d29cc040fe5afe1a14da1fe1b0 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 11:07:22 +0000 Subject: [PATCH] =?UTF-8?q?fix(gigs):=20visible=20boost=20=E2=80=94=20opaq?= =?UTF-8?q?ue=20menu,=20feedback,=20badge,=20top=20placement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three issues with the boost feature: - The actions dropdown used bg-popover, which has no theme token defined, so the menu rendered transparent. Switched to bg-card (opaque). - Boosting gave no success/failure indication. handleBoost now surfaces an explicit dialog alert on both outcomes. - Boosted gigs are now visibly distinct and prioritized: a "Boosted" badge on the gig card + dashboard, and boosted gigs (within the 7-day window) are pinned to the top of /gigs and /for-hire ahead of everything else for the duration of the boost. The /gigs and /for-hire listings previously sorted purely by created_at via their own inline queries, so boosting had no effect there. Extracted that into src/lib/gigs/fetch-gigs.ts, which applies the shared filters and, for the default newest sort, pins active boosts on top (then the rest by recency) with correct pagination. isGigBoosted()/BOOST_ACTIVE_MS added to src/lib/boost.ts (window == cooldown == 7 days). Co-Authored-By: Claude Opus 4.8 --- src/app/dashboard/gigs/page.tsx | 9 +- src/app/for-hire/[[...tags]]/page.tsx | 93 +++------------ src/app/gigs/[[...tags]]/page.tsx | 99 +++------------ src/components/gigs/GigActions.tsx | 15 ++- src/components/gigs/GigCard.tsx | 24 +++- src/lib/boost.test.ts | 23 +++- src/lib/boost.ts | 18 +++ src/lib/gigs/fetch-gigs.ts | 166 ++++++++++++++++++++++++++ 8 files changed, 273 insertions(+), 174 deletions(-) create mode 100644 src/lib/gigs/fetch-gigs.ts diff --git a/src/app/dashboard/gigs/page.tsx b/src/app/dashboard/gigs/page.tsx index 7ca66276..d3baf74b 100644 --- a/src/app/dashboard/gigs/page.tsx +++ b/src/app/dashboard/gigs/page.tsx @@ -10,8 +10,9 @@ import { type PendingApplication, } from "@/components/gigs/PendingApplicantsDropdown"; import { ApproveAllButton } from "@/components/gigs/ApproveAllButton"; -import { Plus, ArrowLeft, Eye, Users, Briefcase, Archive } from "lucide-react"; +import { Plus, ArrowLeft, Eye, Users, Briefcase, Archive, Rocket } from "lucide-react"; import { AddToPortfolioPrompt } from "@/components/portfolio/AddToPortfolioPrompt"; +import { isGigBoosted } from "@/lib/boost"; export const metadata = { title: "My Gigs | ugig.net", @@ -155,6 +156,12 @@ export default async function MyGigsPage({ searchParams }: MyGigsPageProps) { > {gig.status} + {isGigBoosted(gig) && ( + + + Boosted + + )}

diff --git a/src/app/for-hire/[[...tags]]/page.tsx b/src/app/for-hire/[[...tags]]/page.tsx index b29e2258..988aded5 100644 --- a/src/app/for-hire/[[...tags]]/page.tsx +++ b/src/app/for-hire/[[...tags]]/page.tsx @@ -8,7 +8,8 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Header } from "@/components/layout/Header"; import { parsePageParam } from "@/lib/pagination"; -import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; +import { fetchGigs } from "@/lib/gigs/fetch-gigs"; +import type { GigCardData } from "@/components/gigs/GigCard"; import { Briefcase } from "lucide-react"; interface GigsPageProps { @@ -68,86 +69,22 @@ async function GigsList({ ? queryParams.skill.split(",").map(decodeURIComponent) : tags?.[0]?.split(",").map(decodeURIComponent) || []; - // Build query - let query = supabase - .from("gigs") - .select( - ` - *, - poster:profiles!poster_id ( - id, - username, - full_name, - avatar_url, - account_type, - verified, - verification_type - ) - `, - { count: "exact" } - ) - .eq("status", "active") - .eq("listing_type", "for_hire"); - - // Filter by search query - if (queryParams.search) { - const safeSearch = escapePostgrestSearchValue(queryParams.search); - query = query.or( - `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%` - ); - } - - // Filter by category - if (queryParams.category) { - query = query.eq("category", queryParams.category); - } - - // Filter by location type - if ( - queryParams.location_type && - ["remote", "onsite", "hybrid"].includes(queryParams.location_type) - ) { - query = query.eq("location_type", queryParams.location_type as "remote" | "onsite" | "hybrid"); - } - - // Filter by skill tags - // We need to filter gigs that have ANY of the tags in their skills_required - if (tagList.length > 0) { - // Build expanded tag list with common casings to handle case-insensitive matching - const expandedTags = new Set(); - for (const tag of tagList) { - expandedTags.add(tag); - expandedTags.add(tag.toLowerCase()); - expandedTags.add(tag.charAt(0).toUpperCase() + tag.slice(1)); // Title case - expandedTags.add(tag.toUpperCase()); - // Handle multi-word: "node.js" → "Node.js", "next.js" → "Next.js" - expandedTags.add(tag.replace(/\b\w/g, (c) => c.toUpperCase())); - } - query = query.overlaps("skills_required", [...expandedTags]); - } - - // Apply sorting - switch (queryParams.sort) { - case "oldest": - query = query.order("created_at", { ascending: true }); - break; - case "budget_high": - query = query.order("budget_max", { ascending: false, nullsFirst: false }); - break; - case "budget_low": - query = query.order("budget_min", { ascending: true, nullsFirst: false }); - break; - default: - query = query.order("created_at", { ascending: false }); - } - // Pagination const page = parsePageParam(queryParams.page); const limit = 20; - const offset = (page - 1) * limit; - query = query.range(offset, offset + limit - 1); - const { data: gigs, count } = await query; + const { gigs, count } = await fetchGigs(supabase, { + listingType: "for_hire", + filters: { + search: queryParams.search, + category: queryParams.category, + locationType: queryParams.location_type, + tags: tagList, + }, + sort: queryParams.sort, + page, + limit, + }); if (!gigs || gigs.length === 0) { return ( @@ -193,7 +130,7 @@ async function GigsList({

- {gigs.map((gig) => ( + {(gigs as unknown as GigCardData[]).map((gig) => ( ))}
diff --git a/src/app/gigs/[[...tags]]/page.tsx b/src/app/gigs/[[...tags]]/page.tsx index be97938f..79204829 100644 --- a/src/app/gigs/[[...tags]]/page.tsx +++ b/src/app/gigs/[[...tags]]/page.tsx @@ -9,7 +9,8 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Header } from "@/components/layout/Header"; import { hasActiveGigFilters } from "@/lib/gigs/filter-state"; import { parsePageParam } from "@/lib/pagination"; -import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; +import { fetchGigs } from "@/lib/gigs/fetch-gigs"; +import type { GigCardData } from "@/components/gigs/GigCard"; import { Briefcase } from "lucide-react"; interface GigsPageProps { @@ -70,91 +71,23 @@ async function GigsList({ ? queryParams.skill.split(",").map(decodeURIComponent) : tags?.[0]?.split(",").map(decodeURIComponent) || []; - // Build query - let query = supabase - .from("gigs") - .select( - ` - *, - poster:profiles!poster_id ( - id, - username, - full_name, - avatar_url, - account_type, - verified, - verification_type - ) - `, - { count: "exact" } - ) - .eq("status", "active") - .eq("listing_type", "hiring"); - - // Filter by search query - if (queryParams.search) { - const safeSearch = escapePostgrestSearchValue(queryParams.search); - query = query.or( - `title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%` - ); - } - - // Filter by category - if (queryParams.category) { - query = query.eq("category", queryParams.category); - } - - // Filter by location type - if ( - queryParams.location_type && - ["remote", "onsite", "hybrid"].includes(queryParams.location_type) - ) { - query = query.eq("location_type", queryParams.location_type as "remote" | "onsite" | "hybrid"); - } - - // Filter by budget type - if (queryParams.budget_type) { - query = query.eq("budget_type", queryParams.budget_type as any); - } - - // Filter by skill tags - // We need to filter gigs that have ANY of the tags in their skills_required - if (tagList.length > 0) { - // Build expanded tag list with common casings to handle case-insensitive matching - const expandedTags = new Set(); - for (const tag of tagList) { - expandedTags.add(tag); - expandedTags.add(tag.toLowerCase()); - expandedTags.add(tag.charAt(0).toUpperCase() + tag.slice(1)); // Title case - expandedTags.add(tag.toUpperCase()); - // Handle multi-word: "node.js" → "Node.js", "next.js" → "Next.js" - expandedTags.add(tag.replace(/\b\w/g, (c) => c.toUpperCase())); - } - query = query.overlaps("skills_required", [...expandedTags]); - } - - // Apply sorting - switch (queryParams.sort) { - case "oldest": - query = query.order("created_at", { ascending: true }); - break; - case "budget_high": - query = query.order("budget_max", { ascending: false, nullsFirst: false }); - break; - case "budget_low": - query = query.order("budget_min", { ascending: true, nullsFirst: false }); - break; - default: - query = query.order("created_at", { ascending: false }); - } - // Pagination const page = parsePageParam(queryParams.page); const limit = 20; - const offset = (page - 1) * limit; - query = query.range(offset, offset + limit - 1); - const { data: gigs, count } = await query; + const { gigs, count } = await fetchGigs(supabase, { + listingType: "hiring", + filters: { + search: queryParams.search, + category: queryParams.category, + locationType: queryParams.location_type, + budgetType: queryParams.budget_type, + tags: tagList, + }, + sort: queryParams.sort, + page, + limit, + }); const hasActiveFilters = hasActiveGigFilters(queryParams, tagList); if (!gigs || gigs.length === 0) { @@ -202,7 +135,7 @@ async function GigsList({

- {gigs.map((gig) => ( + {(gigs as unknown as GigCardData[]).map((gig) => ( ))}
diff --git a/src/components/gigs/GigActions.tsx b/src/components/gigs/GigActions.tsx index 529d3adc..094175fc 100644 --- a/src/components/gigs/GigActions.tsx +++ b/src/components/gigs/GigActions.tsx @@ -28,7 +28,7 @@ interface GigActionsProps { export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsProps) { const router = useRouter(); - const { confirm } = useDialog(); + const { confirm, alert } = useDialog(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -61,14 +61,17 @@ export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsPr const result = await gigsApi.boost(gigId); + setIsOpen(false); + setIsLoading(false); + if (result.error) { - setError(result.error); - setIsLoading(false); + await alert(result.error); return; } - setIsOpen(false); - setIsLoading(false); + await alert( + "Gig boosted! It's pinned to the top of the listing for the next week." + ); router.refresh(); }; @@ -120,7 +123,7 @@ export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsPr className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} /> -
+
{error && (
diff --git a/src/components/gigs/GigCard.tsx b/src/components/gigs/GigCard.tsx index f1d179a8..09fcd9e8 100644 --- a/src/components/gigs/GigCard.tsx +++ b/src/components/gigs/GigCard.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import Image from "next/image"; -import { MapPin, Clock, DollarSign } from "lucide-react"; +import { MapPin, Clock, DollarSign, Rocket } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { AgentBadge } from "@/components/ui/AgentBadge"; import { VerifiedBadge } from "@/components/ui/VerifiedBadge"; @@ -13,11 +13,14 @@ import { linkifyText } from "@/lib/linkify"; import type { Gig, Profile } from "@/types"; import { SatsRangeToUsd } from "./SatsToUsd"; import { ZapButton } from "@/components/zaps/ZapButton"; +import { isGigBoosted } from "@/lib/boost"; + +export type GigCardData = Gig & { + poster?: Pick; +}; interface GigCardProps { - gig: Gig & { - poster?: Pick; - }; + gig: GigCardData; showSaveButton?: boolean; isSaved?: boolean; onSaveChange?: (saved: boolean) => void; @@ -85,11 +88,16 @@ export function GigCard({ const isForHire = gig.listing_type === "for_hire"; const detailHref = isForHire ? `/for-hire/${gig.id}` : `/gigs/${gig.id}`; + const boosted = isGigBoosted(gig); return (
@@ -131,6 +139,12 @@ export function GigCard({
+ {boosted && ( + + + Boosted + + )} {gig.listing_type === "for_hire" ? ( For Hire ) : ( diff --git a/src/lib/boost.test.ts b/src/lib/boost.test.ts index d2fe22ee..e9fb36ea 100644 --- a/src/lib/boost.test.ts +++ b/src/lib/boost.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { getBoostEligibility, BOOST_COOLDOWN_DAYS } from "./boost"; +import { getBoostEligibility, isGigBoosted, BOOST_COOLDOWN_DAYS } from "./boost"; const DAY_MS = 24 * 60 * 60 * 1000; const now = new Date("2026-06-14T00:00:00.000Z"); @@ -54,3 +54,24 @@ describe("getBoostEligibility", () => { expect(getBoostEligibility({ created_at: "not-a-date" }, now).eligible).toBe(true); }); }); + +describe("isGigBoosted", () => { + it("is false when never boosted", () => { + expect(isGigBoosted({ boosted_at: null }, now)).toBe(false); + expect(isGigBoosted({}, now)).toBe(false); + }); + + it("is true within the active window", () => { + expect(isGigBoosted({ boosted_at: daysAgo(0) }, now)).toBe(true); + expect(isGigBoosted({ boosted_at: daysAgo(BOOST_COOLDOWN_DAYS - 1) }, now)).toBe(true); + }); + + it("is false once the active window has elapsed", () => { + expect(isGigBoosted({ boosted_at: daysAgo(BOOST_COOLDOWN_DAYS) }, now)).toBe(false); + expect(isGigBoosted({ boosted_at: daysAgo(30) }, now)).toBe(false); + }); + + it("is false for an unparseable timestamp", () => { + expect(isGigBoosted({ boosted_at: "nope" }, now)).toBe(false); + }); +}); diff --git a/src/lib/boost.ts b/src/lib/boost.ts index d4fd1c49..474a8c47 100644 --- a/src/lib/boost.ts +++ b/src/lib/boost.ts @@ -6,6 +6,24 @@ export const BOOST_COOLDOWN_DAYS = 7; const COOLDOWN_MS = BOOST_COOLDOWN_DAYS * 24 * 60 * 60 * 1000; +/** + * How long a boost stays "active" — i.e. the gig shows the Boosted badge and is + * pinned to the top of the listing. Equal to the cooldown, so a gig is pinned for + * exactly the window during which it cannot be boosted again. + */ +export const BOOST_ACTIVE_MS = COOLDOWN_MS; + +/** True when the gig was boosted within the active window and should be pinned/badged. */ +export function isGigBoosted( + gig: { boosted_at?: string | null }, + now: Date = new Date() +): boolean { + if (!gig.boosted_at) return false; + const boostedMs = new Date(gig.boosted_at).getTime(); + if (!Number.isFinite(boostedMs)) return false; + return now.getTime() - boostedMs < BOOST_ACTIVE_MS; +} + interface BoostableGig { created_at?: string | null; boosted_at?: string | null; diff --git a/src/lib/gigs/fetch-gigs.ts b/src/lib/gigs/fetch-gigs.ts new file mode 100644 index 00000000..1fb75f26 --- /dev/null +++ b/src/lib/gigs/fetch-gigs.ts @@ -0,0 +1,166 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { escapePostgrestSearchValue } from "@/lib/security/sanitize"; +import { BOOST_ACTIVE_MS } from "@/lib/boost"; + +// Shared gig-listing fetch used by /gigs and /for-hire. Beyond the usual filters +// and sorting it pins "active" boosts (boosted within BOOST_ACTIVE_MS) to the very +// top of the default (newest) listing, ahead of everything else, for the boost window. + +export const GIG_LIST_SELECT = ` + *, + poster:profiles!poster_id ( + id, + username, + full_name, + avatar_url, + account_type, + verified, + verification_type + ) +`; + +export interface GigListFilters { + search?: string; + category?: string; + locationType?: string; + budgetType?: string; + tags: string[]; +} + +export interface FetchGigsOptions { + listingType: "hiring" | "for_hire"; + filters: GigListFilters; + sort?: string; + page: number; + limit: number; +} + +export interface FetchGigsResult { + gigs: Record[]; + count: number; +} + +const PINNED_SORTS = new Set([undefined, "", "newest"]); + +type GigQuery = any; + +function applyFilters(query: GigQuery, filters: GigListFilters): GigQuery { + if (filters.search) { + const safeSearch = escapePostgrestSearchValue(filters.search); + query = query.or(`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%`); + } + + if (filters.category) { + query = query.eq("category", filters.category); + } + + if ( + filters.locationType && + ["remote", "onsite", "hybrid"].includes(filters.locationType) + ) { + query = query.eq("location_type", filters.locationType); + } + + if (filters.budgetType) { + query = query.eq("budget_type", filters.budgetType); + } + + if (filters.tags.length > 0) { + // Expand common casings so the (case-sensitive) array overlap matches. + const expandedTags = new Set(); + for (const tag of filters.tags) { + expandedTags.add(tag); + expandedTags.add(tag.toLowerCase()); + expandedTags.add(tag.charAt(0).toUpperCase() + tag.slice(1)); + expandedTags.add(tag.toUpperCase()); + expandedTags.add(tag.replace(/\b\w/g, (c) => c.toUpperCase())); + } + query = query.overlaps("skills_required", [...expandedTags]); + } + + return query; +} + +export async function fetchGigs( + supabase: SupabaseClient, + { listingType, filters, sort, page, limit }: FetchGigsOptions +): Promise { + const base = (opts?: { select?: string; head?: boolean }): GigQuery => { + const query = supabase + .from("gigs") + .select(opts?.select ?? GIG_LIST_SELECT, { + count: "exact", + head: opts?.head ?? false, + }) + .eq("status", "active") + .eq("listing_type", listingType); + return applyFilters(query, filters); + }; + + const offset = (page - 1) * limit; + + // Non-default sorts respect the user's explicit choice — no boost pinning. + if (!PINNED_SORTS.has(sort)) { + let query = base(); + switch (sort) { + case "oldest": + query = query.order("created_at", { ascending: true }); + break; + case "budget_high": + query = query.order("budget_max", { ascending: false, nullsFirst: false }); + break; + case "budget_low": + query = query.order("budget_min", { ascending: true, nullsFirst: false }); + break; + default: + query = query.order("created_at", { ascending: false }); + } + const { data, count } = await query.range(offset, offset + limit - 1); + return { gigs: data ?? [], count: count ?? 0 }; + } + + // Default (newest): pinned active boosts first, then the rest by recency. + // Drop milliseconds so the timestamp has no characters PostgREST treats specially. + const cutoff = new Date(Date.now() - BOOST_ACTIVE_MS) + .toISOString() + .replace(/\.\d{3}Z$/, "Z"); + const notBoosted = `boosted_at.is.null,boosted_at.lt.${cutoff}`; + + // How many active-boosted gigs match the filters (drives where the page splits). + const { count: boostedCount } = await base({ select: "id", head: true }).gte( + "boosted_at", + cutoff + ); + const boostedTotal = boostedCount ?? 0; + + // Boosted slice for this page. + const boostedStart = Math.min(offset, boostedTotal); + const boostedEnd = Math.min(offset + limit, boostedTotal); // exclusive + let boosted: Record[] = []; + if (boostedEnd > boostedStart) { + const { data } = await base() + .gte("boosted_at", cutoff) + .order("boosted_at", { ascending: false }) + .range(boostedStart, boostedEnd - 1); + boosted = data ?? []; + } + + // Remaining slots filled from the non-boosted stream by recency. + const needed = limit - boosted.length; + const normalStart = Math.max(0, offset - boostedTotal); + let normal: Record[] = []; + let normalTotal = 0; + if (needed > 0) { + const { data, count } = await base() + .or(notBoosted) + .order("created_at", { ascending: false }) + .range(normalStart, normalStart + needed - 1); + normal = data ?? []; + normalTotal = count ?? 0; + } else { + const { count } = await base({ select: "id", head: true }).or(notBoosted); + normalTotal = count ?? 0; + } + + return { gigs: [...boosted, ...normal], count: boostedTotal + normalTotal }; +}