Skip to content
Merged
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
9 changes: 8 additions & 1 deletion src/app/dashboard/gigs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -155,6 +156,12 @@ export default async function MyGigsPage({ searchParams }: MyGigsPageProps) {
>
{gig.status}
</Badge>
{isGigBoosted(gig) && (
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 flex items-center gap-1">
<Rocket className="h-3 w-3" />
Boosted
</Badge>
)}
</div>

<p className="text-muted-foreground text-sm mb-4 line-clamp-2 whitespace-pre-wrap break-words">
Expand Down
93 changes: 15 additions & 78 deletions src/app/for-hire/[[...tags]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>();
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 (
Expand Down Expand Up @@ -193,7 +130,7 @@ async function GigsList({
</p>

<div className="space-y-4">
{gigs.map((gig) => (
{(gigs as unknown as GigCardData[]).map((gig) => (
<GigCard key={gig.id} gig={gig} highlightTags={tagList} />
))}
</div>
Expand Down
99 changes: 16 additions & 83 deletions src/app/gigs/[[...tags]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string>();
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) {
Expand Down Expand Up @@ -202,7 +135,7 @@ async function GigsList({
</p>

<div className="space-y-4">
{gigs.map((gig) => (
{(gigs as unknown as GigCardData[]).map((gig) => (
<GigCard key={gig.id} gig={gig} highlightTags={tagList} />
))}
</div>
Expand Down
15 changes: 9 additions & 6 deletions src/components/gigs/GigActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -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();
};

Expand Down Expand Up @@ -120,7 +123,7 @@ export function GigActions({ gigId, status, createdAt, boostedAt }: GigActionsPr
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
/>
<div className="absolute right-0 top-full mt-1 w-48 bg-popover border border-border rounded-lg shadow-lg z-20">
<div className="absolute right-0 top-full mt-1 w-48 bg-card border border-border rounded-lg shadow-lg z-20">
<div className="p-1">
{error && (
<div className="px-3 py-2 text-xs text-destructive">
Expand Down
24 changes: 19 additions & 5 deletions src/components/gigs/GigCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Profile, "id" | "username" | "full_name" | "avatar_url" | "account_type" | "verified" | "verification_type">;
};

interface GigCardProps {
gig: Gig & {
poster?: Pick<Profile, "id" | "username" | "full_name" | "avatar_url" | "account_type" | "verified" | "verification_type">;
};
gig: GigCardData;
showSaveButton?: boolean;
isSaved?: boolean;
onSaveChange?: (saved: boolean) => void;
Expand Down Expand Up @@ -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 (
<Link
href={detailHref}
className="block p-6 border border-border rounded-lg shadow-sm hover:shadow-md hover:border-primary/40 transition-all duration-200 bg-card"
className={`block p-6 border rounded-lg shadow-sm hover:shadow-md transition-all duration-200 bg-card ${
boosted
? "border-amber-400 ring-1 ring-amber-400/40 hover:border-amber-500"
: "border-border hover:border-primary/40"
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
Expand Down Expand Up @@ -131,6 +139,12 @@ export function GigCard({
</div>

<div className="flex flex-wrap gap-2 mt-4">
{boosted && (
<Badge className="font-medium bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200 flex items-center gap-1">
<Rocket className="h-3 w-3" />
Boosted
</Badge>
)}
{gig.listing_type === "for_hire" ? (
<Badge className="font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">For Hire</Badge>
) : (
Expand Down
23 changes: 22 additions & 1 deletion src/lib/boost.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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);
});
});
Loading
Loading