Skip to content

Commit b435532

Browse files
ralyodioclaude
andauthored
fix(gigs): visible boost — opaque menu, feedback, badge, top placement (#480)
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 <noreply@anthropic.com>
1 parent 97aa847 commit b435532

8 files changed

Lines changed: 273 additions & 174 deletions

File tree

src/app/dashboard/gigs/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ import {
1010
type PendingApplication,
1111
} from "@/components/gigs/PendingApplicantsDropdown";
1212
import { ApproveAllButton } from "@/components/gigs/ApproveAllButton";
13-
import { Plus, ArrowLeft, Eye, Users, Briefcase, Archive } from "lucide-react";
13+
import { Plus, ArrowLeft, Eye, Users, Briefcase, Archive, Rocket } from "lucide-react";
1414
import { AddToPortfolioPrompt } from "@/components/portfolio/AddToPortfolioPrompt";
15+
import { isGigBoosted } from "@/lib/boost";
1516

1617
export const metadata = {
1718
title: "My Gigs | ugig.net",
@@ -155,6 +156,12 @@ export default async function MyGigsPage({ searchParams }: MyGigsPageProps) {
155156
>
156157
{gig.status}
157158
</Badge>
159+
{isGigBoosted(gig) && (
160+
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 flex items-center gap-1">
161+
<Rocket className="h-3 w-3" />
162+
Boosted
163+
</Badge>
164+
)}
158165
</div>
159166

160167
<p className="text-muted-foreground text-sm mb-4 line-clamp-2 whitespace-pre-wrap break-words">

src/app/for-hire/[[...tags]]/page.tsx

Lines changed: 15 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import { Button } from "@/components/ui/button";
88
import { Skeleton } from "@/components/ui/skeleton";
99
import { Header } from "@/components/layout/Header";
1010
import { parsePageParam } from "@/lib/pagination";
11-
import { escapePostgrestSearchValue } from "@/lib/security/sanitize";
11+
import { fetchGigs } from "@/lib/gigs/fetch-gigs";
12+
import type { GigCardData } from "@/components/gigs/GigCard";
1213
import { Briefcase } from "lucide-react";
1314

1415
interface GigsPageProps {
@@ -68,86 +69,22 @@ async function GigsList({
6869
? queryParams.skill.split(",").map(decodeURIComponent)
6970
: tags?.[0]?.split(",").map(decodeURIComponent) || [];
7071

71-
// Build query
72-
let query = supabase
73-
.from("gigs")
74-
.select(
75-
`
76-
*,
77-
poster:profiles!poster_id (
78-
id,
79-
username,
80-
full_name,
81-
avatar_url,
82-
account_type,
83-
verified,
84-
verification_type
85-
)
86-
`,
87-
{ count: "exact" }
88-
)
89-
.eq("status", "active")
90-
.eq("listing_type", "for_hire");
91-
92-
// Filter by search query
93-
if (queryParams.search) {
94-
const safeSearch = escapePostgrestSearchValue(queryParams.search);
95-
query = query.or(
96-
`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%`
97-
);
98-
}
99-
100-
// Filter by category
101-
if (queryParams.category) {
102-
query = query.eq("category", queryParams.category);
103-
}
104-
105-
// Filter by location type
106-
if (
107-
queryParams.location_type &&
108-
["remote", "onsite", "hybrid"].includes(queryParams.location_type)
109-
) {
110-
query = query.eq("location_type", queryParams.location_type as "remote" | "onsite" | "hybrid");
111-
}
112-
113-
// Filter by skill tags
114-
// We need to filter gigs that have ANY of the tags in their skills_required
115-
if (tagList.length > 0) {
116-
// Build expanded tag list with common casings to handle case-insensitive matching
117-
const expandedTags = new Set<string>();
118-
for (const tag of tagList) {
119-
expandedTags.add(tag);
120-
expandedTags.add(tag.toLowerCase());
121-
expandedTags.add(tag.charAt(0).toUpperCase() + tag.slice(1)); // Title case
122-
expandedTags.add(tag.toUpperCase());
123-
// Handle multi-word: "node.js" → "Node.js", "next.js" → "Next.js"
124-
expandedTags.add(tag.replace(/\b\w/g, (c) => c.toUpperCase()));
125-
}
126-
query = query.overlaps("skills_required", [...expandedTags]);
127-
}
128-
129-
// Apply sorting
130-
switch (queryParams.sort) {
131-
case "oldest":
132-
query = query.order("created_at", { ascending: true });
133-
break;
134-
case "budget_high":
135-
query = query.order("budget_max", { ascending: false, nullsFirst: false });
136-
break;
137-
case "budget_low":
138-
query = query.order("budget_min", { ascending: true, nullsFirst: false });
139-
break;
140-
default:
141-
query = query.order("created_at", { ascending: false });
142-
}
143-
14472
// Pagination
14573
const page = parsePageParam(queryParams.page);
14674
const limit = 20;
147-
const offset = (page - 1) * limit;
148-
query = query.range(offset, offset + limit - 1);
14975

150-
const { data: gigs, count } = await query;
76+
const { gigs, count } = await fetchGigs(supabase, {
77+
listingType: "for_hire",
78+
filters: {
79+
search: queryParams.search,
80+
category: queryParams.category,
81+
locationType: queryParams.location_type,
82+
tags: tagList,
83+
},
84+
sort: queryParams.sort,
85+
page,
86+
limit,
87+
});
15188

15289
if (!gigs || gigs.length === 0) {
15390
return (
@@ -193,7 +130,7 @@ async function GigsList({
193130
</p>
194131

195132
<div className="space-y-4">
196-
{gigs.map((gig) => (
133+
{(gigs as unknown as GigCardData[]).map((gig) => (
197134
<GigCard key={gig.id} gig={gig} highlightTags={tagList} />
198135
))}
199136
</div>

src/app/gigs/[[...tags]]/page.tsx

Lines changed: 16 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { Skeleton } from "@/components/ui/skeleton";
99
import { Header } from "@/components/layout/Header";
1010
import { hasActiveGigFilters } from "@/lib/gigs/filter-state";
1111
import { parsePageParam } from "@/lib/pagination";
12-
import { escapePostgrestSearchValue } from "@/lib/security/sanitize";
12+
import { fetchGigs } from "@/lib/gigs/fetch-gigs";
13+
import type { GigCardData } from "@/components/gigs/GigCard";
1314
import { Briefcase } from "lucide-react";
1415

1516
interface GigsPageProps {
@@ -70,91 +71,23 @@ async function GigsList({
7071
? queryParams.skill.split(",").map(decodeURIComponent)
7172
: tags?.[0]?.split(",").map(decodeURIComponent) || [];
7273

73-
// Build query
74-
let query = supabase
75-
.from("gigs")
76-
.select(
77-
`
78-
*,
79-
poster:profiles!poster_id (
80-
id,
81-
username,
82-
full_name,
83-
avatar_url,
84-
account_type,
85-
verified,
86-
verification_type
87-
)
88-
`,
89-
{ count: "exact" }
90-
)
91-
.eq("status", "active")
92-
.eq("listing_type", "hiring");
93-
94-
// Filter by search query
95-
if (queryParams.search) {
96-
const safeSearch = escapePostgrestSearchValue(queryParams.search);
97-
query = query.or(
98-
`title.ilike.%${safeSearch}%,description.ilike.%${safeSearch}%`
99-
);
100-
}
101-
102-
// Filter by category
103-
if (queryParams.category) {
104-
query = query.eq("category", queryParams.category);
105-
}
106-
107-
// Filter by location type
108-
if (
109-
queryParams.location_type &&
110-
["remote", "onsite", "hybrid"].includes(queryParams.location_type)
111-
) {
112-
query = query.eq("location_type", queryParams.location_type as "remote" | "onsite" | "hybrid");
113-
}
114-
115-
// Filter by budget type
116-
if (queryParams.budget_type) {
117-
query = query.eq("budget_type", queryParams.budget_type as any);
118-
}
119-
120-
// Filter by skill tags
121-
// We need to filter gigs that have ANY of the tags in their skills_required
122-
if (tagList.length > 0) {
123-
// Build expanded tag list with common casings to handle case-insensitive matching
124-
const expandedTags = new Set<string>();
125-
for (const tag of tagList) {
126-
expandedTags.add(tag);
127-
expandedTags.add(tag.toLowerCase());
128-
expandedTags.add(tag.charAt(0).toUpperCase() + tag.slice(1)); // Title case
129-
expandedTags.add(tag.toUpperCase());
130-
// Handle multi-word: "node.js" → "Node.js", "next.js" → "Next.js"
131-
expandedTags.add(tag.replace(/\b\w/g, (c) => c.toUpperCase()));
132-
}
133-
query = query.overlaps("skills_required", [...expandedTags]);
134-
}
135-
136-
// Apply sorting
137-
switch (queryParams.sort) {
138-
case "oldest":
139-
query = query.order("created_at", { ascending: true });
140-
break;
141-
case "budget_high":
142-
query = query.order("budget_max", { ascending: false, nullsFirst: false });
143-
break;
144-
case "budget_low":
145-
query = query.order("budget_min", { ascending: true, nullsFirst: false });
146-
break;
147-
default:
148-
query = query.order("created_at", { ascending: false });
149-
}
150-
15174
// Pagination
15275
const page = parsePageParam(queryParams.page);
15376
const limit = 20;
154-
const offset = (page - 1) * limit;
155-
query = query.range(offset, offset + limit - 1);
15677

157-
const { data: gigs, count } = await query;
78+
const { gigs, count } = await fetchGigs(supabase, {
79+
listingType: "hiring",
80+
filters: {
81+
search: queryParams.search,
82+
category: queryParams.category,
83+
locationType: queryParams.location_type,
84+
budgetType: queryParams.budget_type,
85+
tags: tagList,
86+
},
87+
sort: queryParams.sort,
88+
page,
89+
limit,
90+
});
15891
const hasActiveFilters = hasActiveGigFilters(queryParams, tagList);
15992

16093
if (!gigs || gigs.length === 0) {
@@ -202,7 +135,7 @@ async function GigsList({
202135
</p>
203136

204137
<div className="space-y-4">
205-
{gigs.map((gig) => (
138+
{(gigs as unknown as GigCardData[]).map((gig) => (
206139
<GigCard key={gig.id} gig={gig} highlightTags={tagList} />
207140
))}
208141
</div>

src/components/gigs/GigActions.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ interface GigActionsProps {
2828

2929
export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsProps) {
3030
const router = useRouter();
31-
const { confirm } = useDialog();
31+
const { confirm, alert } = useDialog();
3232
const [isOpen, setIsOpen] = useState(false);
3333
const [isLoading, setIsLoading] = useState(false);
3434
const [error, setError] = useState<string | null>(null);
@@ -61,14 +61,17 @@ export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsPr
6161

6262
const result = await gigsApi.boost(gigId);
6363

64+
setIsOpen(false);
65+
setIsLoading(false);
66+
6467
if (result.error) {
65-
setError(result.error);
66-
setIsLoading(false);
68+
await alert(result.error);
6769
return;
6870
}
6971

70-
setIsOpen(false);
71-
setIsLoading(false);
72+
await alert(
73+
"Gig boosted! It's pinned to the top of the listing for the next week."
74+
);
7275
router.refresh();
7376
};
7477

@@ -120,7 +123,7 @@ export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsPr
120123
className="fixed inset-0 z-10"
121124
onClick={() => setIsOpen(false)}
122125
/>
123-
<div className="absolute right-0 top-full mt-1 w-48 bg-popover border border-border rounded-lg shadow-lg z-20">
126+
<div className="absolute right-0 top-full mt-1 w-48 bg-card border border-border rounded-lg shadow-lg z-20">
124127
<div className="p-1">
125128
{error && (
126129
<div className="px-3 py-2 text-xs text-destructive">

src/components/gigs/GigCard.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import Link from "next/link";
44
import Image from "next/image";
5-
import { MapPin, Clock, DollarSign } from "lucide-react";
5+
import { MapPin, Clock, DollarSign, Rocket } from "lucide-react";
66
import { Badge } from "@/components/ui/badge";
77
import { AgentBadge } from "@/components/ui/AgentBadge";
88
import { VerifiedBadge } from "@/components/ui/VerifiedBadge";
@@ -13,11 +13,14 @@ import { linkifyText } from "@/lib/linkify";
1313
import type { Gig, Profile } from "@/types";
1414
import { SatsRangeToUsd } from "./SatsToUsd";
1515
import { ZapButton } from "@/components/zaps/ZapButton";
16+
import { isGigBoosted } from "@/lib/boost";
17+
18+
export type GigCardData = Gig & {
19+
poster?: Pick<Profile, "id" | "username" | "full_name" | "avatar_url" | "account_type" | "verified" | "verification_type">;
20+
};
1621

1722
interface GigCardProps {
18-
gig: Gig & {
19-
poster?: Pick<Profile, "id" | "username" | "full_name" | "avatar_url" | "account_type" | "verified" | "verification_type">;
20-
};
23+
gig: GigCardData;
2124
showSaveButton?: boolean;
2225
isSaved?: boolean;
2326
onSaveChange?: (saved: boolean) => void;
@@ -85,11 +88,16 @@ export function GigCard({
8588

8689
const isForHire = gig.listing_type === "for_hire";
8790
const detailHref = isForHire ? `/for-hire/${gig.id}` : `/gigs/${gig.id}`;
91+
const boosted = isGigBoosted(gig);
8892

8993
return (
9094
<Link
9195
href={detailHref}
92-
className="block p-6 border border-border rounded-lg shadow-sm hover:shadow-md hover:border-primary/40 transition-all duration-200 bg-card"
96+
className={`block p-6 border rounded-lg shadow-sm hover:shadow-md transition-all duration-200 bg-card ${
97+
boosted
98+
? "border-amber-400 ring-1 ring-amber-400/40 hover:border-amber-500"
99+
: "border-border hover:border-primary/40"
100+
}`}
93101
>
94102
<div className="flex items-start justify-between gap-4">
95103
<div className="flex-1 min-w-0">
@@ -131,6 +139,12 @@ export function GigCard({
131139
</div>
132140

133141
<div className="flex flex-wrap gap-2 mt-4">
142+
{boosted && (
143+
<Badge className="font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 flex items-center gap-1">
144+
<Rocket className="h-3 w-3" />
145+
Boosted
146+
</Badge>
147+
)}
134148
{gig.listing_type === "for_hire" ? (
135149
<Badge className="font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">For Hire</Badge>
136150
) : (

src/lib/boost.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { getBoostEligibility, BOOST_COOLDOWN_DAYS } from "./boost";
2+
import { getBoostEligibility, isGigBoosted, BOOST_COOLDOWN_DAYS } from "./boost";
33

44
const DAY_MS = 24 * 60 * 60 * 1000;
55
const now = new Date("2026-06-14T00:00:00.000Z");
@@ -54,3 +54,24 @@ describe("getBoostEligibility", () => {
5454
expect(getBoostEligibility({ created_at: "not-a-date" }, now).eligible).toBe(true);
5555
});
5656
});
57+
58+
describe("isGigBoosted", () => {
59+
it("is false when never boosted", () => {
60+
expect(isGigBoosted({ boosted_at: null }, now)).toBe(false);
61+
expect(isGigBoosted({}, now)).toBe(false);
62+
});
63+
64+
it("is true within the active window", () => {
65+
expect(isGigBoosted({ boosted_at: daysAgo(0) }, now)).toBe(true);
66+
expect(isGigBoosted({ boosted_at: daysAgo(BOOST_COOLDOWN_DAYS - 1) }, now)).toBe(true);
67+
});
68+
69+
it("is false once the active window has elapsed", () => {
70+
expect(isGigBoosted({ boosted_at: daysAgo(BOOST_COOLDOWN_DAYS) }, now)).toBe(false);
71+
expect(isGigBoosted({ boosted_at: daysAgo(30) }, now)).toBe(false);
72+
});
73+
74+
it("is false for an unparseable timestamp", () => {
75+
expect(isGigBoosted({ boosted_at: "nope" }, now)).toBe(false);
76+
});
77+
});

0 commit comments

Comments
 (0)