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) => (
))}