Skip to main content

Sponsor Ads System

The sponsor ads system allows directory users to promote their items through paid sponsorships. The system includes a submission workflow, payment integration, admin approval process, and public display of active sponsor ads.

Source Locations

hooks/use-user-sponsor-ads.ts        # User-facing CRUD + checkout
hooks/use-admin-sponsor-ads.ts # Admin management (approve/reject/cancel)
hooks/use-active-sponsor-ads.ts # Public display of active ads
hooks/use-sponsor-ad-detail.ts # Single ad detail fetch
lib/types/sponsor-ad.ts # Type definitions
app/api/sponsor-ads/ # API routes
route.ts # GET active ads (public)
checkout/route.ts # POST create checkout
user/route.ts # GET/POST user's ads
user/[id]/route.ts # GET/PUT single ad
user/[id]/cancel/route.ts # POST cancel ad
user/[id]/renew/route.ts # POST renew ad
user/stats/route.ts # GET user stats
User Submits --> pending_payment --> User Pays --> pending --> Admin Reviews
|
+-------+-------+
| |
approved rejected
|
active --> expired
|
cancelled

Status Values

StatusDescription
pending_paymentAd created, awaiting payment
pendingPayment received, awaiting admin approval
activeApproved and currently displayed
rejectedAdmin rejected the submission
expiredActive period has ended
cancelledCancelled by user or admin

Interval Types

IntervalDuration
weekly7-day sponsorship
monthly30-day sponsorship

Type Definitions

SponsorAd (Database Schema)

The SponsorAd type comes from the Drizzle schema (lib/db/schema). Key fields include:

  • id, userId, itemSlug, itemName, itemIconUrl, itemCategory
  • status (one of the status values above)
  • interval (weekly or monthly)
  • startDate, endDate
  • paymentProvider, paymentId, subscriptionId, customerId
  • rejectionReason, cancelReason
  • createdAt, updatedAt

SponsorWithItem

Used for display components -- pairs a sponsor ad with its resolved item data:

interface SponsorWithItem {
sponsor: SponsorAd;
item: ItemData | null;
}

SponsorAdStats

Aggregate statistics returned by the stats endpoint:

interface SponsorAdStats {
overview: {
total: number;
pendingPayment: number;
pending: number;
active: number;
rejected: number;
expired: number;
cancelled: number;
};
byInterval: {
weekly: number;
monthly: number;
};
revenue: {
totalRevenue: number;
weeklyRevenue: number;
monthlyRevenue: number;
};
}

useUserSponsorAds

The primary hook for users managing their sponsor ad submissions.

Import

import { useUserSponsorAds } from '@/hooks/use-user-sponsor-ads';

Parameters

interface UseUserSponsorAdsOptions {
page?: number; // default: 1
limit?: number; // default: 10
status?: SponsorAdStatus;
interval?: 'weekly' | 'monthly';
search?: string;
}

Return Value

const {
// Data
sponsorAds, // SponsorAd[]
stats, // SponsorAdStats

// Loading states
isLoading, // boolean - initial fetch
isFetching, // boolean - any fetch including background
isStatsLoading, // boolean - stats query loading
isCreating, // boolean - creation mutation in progress

// Pagination
currentPage, // number
totalPages, // number
totalItems, // number

// Filters
statusFilter, // SponsorAdStatus | undefined
intervalFilter, // 'weekly' | 'monthly' | undefined
search, // string
isSearching, // boolean - debounce in progress

// Actions
createSponsorAd, // (input) => Promise<SponsorAd | null>
cancelSponsorAd, // (id, reason?) => Promise<boolean>
payNow, // (id) => Promise<{ checkoutUrl } | null>
renewSponsorship, // (id) => Promise<{ checkoutUrl } | null>

// Submitting states
isCancelling, // boolean
isPayingNow, // boolean
isRenewing, // boolean

// Filter setters
setStatusFilter, // (status) => void
setIntervalFilter, // (interval) => void
setSearch, // (search) => void
setCurrentPage, // (page) => void
nextPage, // () => void
prevPage, // () => void

// Utility
refreshData, // () => void
} = useUserSponsorAds(options);

Creating a Sponsor Ad

const { createSponsorAd } = useUserSponsorAds();

async function handleSubmit(item) {
const sponsorAd = await createSponsorAd({
itemSlug: item.slug,
itemName: item.name,
itemIconUrl: item.icon,
itemCategory: item.category,
itemDescription: item.description,
interval: 'monthly',
});

if (sponsorAd) {
// Ad created in pending_payment status
// Redirect user to payment
}
}

Payment Flow

After creating a sponsor ad, the user needs to pay. The payNow method creates a checkout session and returns a URL:

const { payNow } = useUserSponsorAds();

async function handlePayment(sponsorAdId: string) {
const result = await payNow(sponsorAdId);
if (result?.checkoutUrl) {
window.location.href = result.checkoutUrl;
}
}

The checkout API (/api/sponsor-ads/checkout) returns:

interface CheckoutResponse {
success: boolean;
data: {
checkoutId: string;
checkoutUrl: string | null;
provider: string;
};
}

Renewing a Sponsorship

Expired or about-to-expire ads can be renewed:

const { renewSponsorship } = useUserSponsorAds();

async function handleRenew(sponsorAdId: string) {
const result = await renewSponsorship(sponsorAdId);
if (result?.checkoutUrl) {
window.location.href = result.checkoutUrl;
}
}

Search with Debouncing

The hook includes built-in search debouncing (300ms delay):

const { search, setSearch, isSearching, sponsorAds } = useUserSponsorAds();

return (
<div>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search your sponsor ads..."
/>
{isSearching && <span>Searching...</span>}
{sponsorAds.map(ad => /* render */)}
</div>
);

useAdminSponsorAds

The admin hook provides management capabilities: approve, reject, cancel, and delete sponsor ads.

Import

import { useAdminSponsorAds } from '@/hooks/use-admin-sponsor-ads';

Parameters

interface UseAdminSponsorAdsOptions {
page?: number;
limit?: number;
status?: SponsorAdStatus;
interval?: SponsorAdIntervalType;
search?: string;
sortBy?: 'createdAt' | 'updatedAt' | 'startDate' | 'endDate' | 'status';
sortOrder?: 'asc' | 'desc';
}

Return Value

const {
// Data
sponsorAds, // SponsorAd[]
stats, // SponsorAdStats | null

// Loading
isLoading,
isSubmitting, // any mutation in progress

// Pagination
currentPage,
totalPages,
totalItems,

// Sorting
sortBy,
sortOrder,

// Actions
approveSponsorAd, // (id, forceApprove?) => Promise<{ success, requiresForceApprove? }>
rejectSponsorAd, // (id, reason) => Promise<boolean>
cancelSponsorAd, // (id, reason?) => Promise<boolean>
deleteSponsorAd, // (id) => Promise<boolean>

// Setters
setSortBy,
setSortOrder,
setCurrentPage,

// Utility
refreshData,
} = useAdminSponsorAds(options);

Approval Workflow

The approval action supports a forceApprove option for cases where payment has not been received:

const { approveSponsorAd } = useAdminSponsorAds();

async function handleApprove(id: string) {
const result = await approveSponsorAd(id);

if (result.requiresForceApprove) {
// Show confirmation dialog
const confirmed = await showDialog(
'Payment not received. Approve anyway?'
);
if (confirmed) {
await approveSponsorAd(id, true);
}
}
}

When the API returns a PAYMENT_NOT_RECEIVED error, the hook catches it and returns requiresForceApprove: true instead of showing a toast error.

Rejection with Reason

Rejections require a reason string that gets stored on the sponsor ad record:

const { rejectSponsorAd } = useAdminSponsorAds();

await rejectSponsorAd(id, 'Content does not meet quality guidelines');

Sorting with Pagination Reset

Changing the sort field or order automatically resets to page 1:

const { setSortBy, setSortOrder, sponsorAds } = useAdminSponsorAds();

// This will reset currentPage to 1
setSortBy('startDate');
setSortOrder('desc');

useActiveSponsorAds

A lightweight hook for fetching active sponsor ads for public display on homepage layouts and sidebars.

Import

import { useActiveSponsorAds } from '@/hooks/use-active-sponsor-ads';

Parameters

interface UseActiveSponsorAdsOptions {
limit?: number; // default: 10
enabled?: boolean; // default: true
}

Return Value

const {
sponsors, // SponsorWithItem[] - sponsor ad + resolved item data
isLoading,
isError,
error,
refetch,
} = useActiveSponsorAds({ limit: 5 });

Usage Example

import { useActiveSponsorAds } from '@/hooks/use-active-sponsor-ads';

function SponsorSidebar() {
const { sponsors, isLoading } = useActiveSponsorAds({ limit: 3 });

if (isLoading || sponsors.length === 0) return null;

return (
<aside className="sponsor-sidebar">
<h3>Sponsored</h3>
{sponsors.map(({ sponsor, item }) => (
<a key={sponsor.id} href={`/items/${sponsor.itemSlug}`}>
{item?.icon && <img src={item.icon} alt={sponsor.itemName} />}
<span>{sponsor.itemName}</span>
</a>
))}
</aside>
);
}

Caching

The hook uses aggressive caching since active sponsors do not change frequently:

SettingValue
staleTime5 minutes
gcTime10 minutes
refetchOnWindowFocusfalse

useSponsorAdDetail

Fetches a single sponsor ad by ID. Used for detail/edit pages.

Import

import { useSponsorAdDetail } from '@/hooks/use-sponsor-ad-detail';

Usage

function SponsorAdDetailPage({ adId }: { adId: string }) {
const { data: sponsorAd, isLoading, error } = useSponsorAdDetail(adId);

if (isLoading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
if (!sponsorAd) return <NotFound />;

return (
<div>
<h1>{sponsorAd.itemName}</h1>
<Badge>{sponsorAd.status}</Badge>
<p>Interval: {sponsorAd.interval}</p>
</div>
);
}

The hook accepts null as the ID, in which case the query is disabled. This is useful for conditional rendering:

const { data } = useSponsorAdDetail(selectedId || null);

API Endpoints

Public Endpoints

MethodEndpointDescription
GET/api/sponsor-adsFetch active sponsor ads for public display

User Endpoints (Authenticated)

MethodEndpointDescription
GET/api/sponsor-ads/userList user's sponsor ads with pagination
POST/api/sponsor-ads/userCreate a new sponsor ad submission
GET/api/sponsor-ads/user/statsFetch user's sponsor ad statistics
GET/api/sponsor-ads/user/{id}Get a specific sponsor ad
POST/api/sponsor-ads/user/{id}/cancelCancel a sponsor ad
POST/api/sponsor-ads/user/{id}/renewRenew an expired sponsorship
POST/api/sponsor-ads/checkoutCreate a payment checkout session

Admin Endpoints

MethodEndpointDescription
GET/api/admin/sponsor-adsList all sponsor ads with filters
POST/api/admin/sponsor-ads/{id}/approveApprove a sponsor ad
POST/api/admin/sponsor-ads/{id}/rejectReject with reason
POST/api/admin/sponsor-ads/{id}/cancelAdmin cancel
DELETE/api/admin/sponsor-ads/{id}Delete a sponsor ad

Complete Submission Workflow

Here is the full workflow from the user's perspective:

Step 1 -- Select an Item

The user chooses which item to sponsor from their dashboard or the item detail page.

Step 2 -- Submit Sponsor Ad

const ad = await createSponsorAd({
itemSlug: 'my-awesome-tool',
itemName: 'My Awesome Tool',
itemIconUrl: '/icons/tool.png',
itemCategory: 'Productivity',
interval: 'monthly',
});
// Status: pending_payment

Step 3 -- Complete Payment

const result = await payNow(ad.id);
window.location.href = result.checkoutUrl;
// After payment: Status changes to pending

Step 4 -- Admin Review

The admin sees the pending ad in their dashboard and can approve or reject:

// Approve
await approveSponsorAd(ad.id);
// Status: active, startDate and endDate are set

// Or reject
await rejectSponsorAd(ad.id, 'Low quality image');
// Status: rejected

Step 5 -- Active Display

Active ads appear in the public-facing components through useActiveSponsorAds.

Step 6 -- Expiration and Renewal

When the sponsorship period ends, the status changes to expired. The user can renew:

const result = await renewSponsorship(ad.id);
window.location.href = result.checkoutUrl;
// After payment and approval: Status returns to active

Stats Dashboard

Both user and admin hooks expose statistics for dashboard displays:

const { stats } = useUserSponsorAds();

// Display in dashboard
<div>
<StatCard label="Active" value={stats.overview.active} />
<StatCard label="Pending" value={stats.overview.pending} />
<StatCard label="Total Revenue" value={`$${stats.revenue.totalRevenue}`} />
<StatCard label="Weekly Ads" value={stats.byInterval.weekly} />
<StatCard label="Monthly Ads" value={stats.byInterval.monthly} />
</div>