# Petdex Ads Feature Plan

## Goal

Implement a full self-serve ads feature for Petdex:

- Public `/advertise` page explaining the developer audience, pricing, viewability billing, acceptable use, and legal terms.
- Authenticated advertiser submission flow for image, title, description, destination link, company/contact details, package, and optional UTM fields.
- Stripe Checkout for prepaid view packages: 1k views for $5, 5k for $10, 10k for $20.
- Auto-activate campaigns after Stripe confirms payment.
- Subtle sponsored cards interleaved into the existing pet gallery.
- Billable impression tracking only after an ad is visible on screen for at least 2 continuous seconds.
- Default outbound tracking query param identifying Petdex as the referrer when custom UTM settings are not supplied.

## Recommended Product Behavior

- Keep `/advertise` public so companies can read pricing and terms before signing in.
- Require Clerk auth for submitting an ad, uploading an ad image, and starting checkout.
- Create campaigns as `pending_payment`; only the Stripe webhook can set them `active`.
- Auto-run paid campaigns immediately after `checkout.session.completed` confirms payment.
- Clearly label feed ads as `Sponsored` and use `rel="noopener noreferrer sponsored"` on outbound links.
- Do not reimburse campaigns removed for policy violations. The advertiser must explicitly accept this before checkout.

## Data Model

Modify `src/lib/db/schema.ts` and add a new SQL migration under `drizzle/`.

Add `ad_campaigns`:

- `id`
- `userId`
- `companyName`
- `contactEmail`
- `title`
- `description`
- `imageUrl`
- `destinationUrl`
- `utmSource`
- `utmMedium`
- `utmCampaign`
- `utmTerm`
- `utmContent`
- `packageViews`
- `priceCents`
- `viewsServed`
- `status`: `pending_payment`, `active`, `exhausted`, `paused`, `deleted`
- `stripeCheckoutSessionId`
- `stripePaymentIntentId`
- `acceptedTermsAt`
- `createdAt`, `updatedAt`, `paidAt`, `activatedAt`, `pausedAt`, `deletedAt`
- `removalReason`

Add `ad_impressions`:

- `id`
- `campaignId`
- `userId` nullable
- `anonymousId` nullable
- `sessionId`
- `requestId`
- `visibleMs`
- `path`
- `locale`
- `userAgentHash`
- `ipHash`
- `createdAt`

Add a unique constraint on `(campaignId, sessionId, requestId)` so retried impression events do not double-count.

Skip a separate order table for v1. Store Stripe session/payment intent IDs on the campaign.

## New Shared Ads Modules

Create:

- `src/lib/ads/packages.ts`
- `src/lib/ads/validation.ts`
- `src/lib/ads/url.ts`
- `src/lib/ads/queries.ts`

`packages.ts` should hardcode server-trusted packages:

```ts
export const AD_PACKAGES = {
  views_1000: { views: 1000, priceCents: 500 },
  views_5000: { views: 5000, priceCents: 1000 },
  views_10000: { views: 10000, priceCents: 2000 },
} as const;
```

Never accept price, view count, or status from the client.

`url.ts` should:

- Validate destination URLs are `https://` only.
- Reject unsafe protocols such as `javascript:`, `data:`, `file:`, and localhost/private hosts.
- Build outbound URLs by preserving existing query params.
- If no custom UTM values are provided, append `utm_source=petdex`.
- If custom UTM fields are provided, apply them without overwriting unrelated existing query params.

`queries.ts` should include:

- Insert campaign.
- Fetch owned pending campaign for checkout.
- Activate campaign from Stripe webhook idempotently.
- Fetch active public feed ads where `status = active`, `deletedAt is null`, and `viewsServed < packageViews`.
- Insert/count impressions transactionally and mark campaigns `exhausted` when the prepaid view budget is reached.

## API Routes

Create `src/app/api/ads/image/presign/route.ts`.

- `POST` only.
- Call `requireSameOrigin(req)`.
- Require Clerk `auth()`.
- Rate limit via `src/lib/ratelimit.ts`.
- Allow `image/png`, `image/jpeg`, and `image/webp`.
- Enforce a small max size, e.g. 2 MB.
- Generate keys under `ads/{userId}/{uuid}.{ext}`.
- Return `{ uploadUrl, publicUrl, key }` from R2 presigning.

Create `src/app/api/ads/route.ts`.

- `POST` creates `pending_payment` campaigns.
- Call `requireSameOrigin(req)`.
- Require Clerk `auth()`.
- Rate limit by user/IP.
- Validate company name, contact email, title, description, destination URL, package ID, R2 image URL, optional UTM fields, and accepted terms.
- Resolve package server-side from `AD_PACKAGES`.
- Insert the campaign with `acceptedTermsAt` and return `{ campaignId }`.

Create `src/app/api/ads/checkout/route.ts`.

- `POST` creates a Stripe Checkout Session for a pending campaign owned by the signed-in user.
- Call `requireSameOrigin(req)`.
- Require Clerk `auth()`.
- Validate campaign ownership and `pending_payment` status.
- Use Stripe inline `price_data` for v1, avoiding Stripe Price ID setup.
- Put `campaignId`, `userId`, and `packageViews` in Stripe metadata.
- Persist `stripeCheckoutSessionId`.
- Return `{ url }` and let the client redirect.

Create `src/app/api/stripe/webhook/route.ts`.

- Do not call `requireSameOrigin`; this route is called by Stripe.
- Read the raw request body and verify `stripe-signature` with `STRIPE_WEBHOOK_SECRET`.
- Handle `checkout.session.completed` only.
- Confirm the session is paid.
- Read `campaignId` from metadata.
- Idempotently update the campaign to `active`, set `paidAt`, `activatedAt`, and `stripePaymentIntentId`.

Create `src/app/api/ads/impression/route.ts`.

- `POST` counts billable views.
- Call `requireSameOrigin(req)`.
- Allow anonymous viewers, but attach Clerk `userId` when available.
- Rate limit by IP/session.
- Validate `campaignId`, `sessionId`, `requestId`, `visibleMs >= 2000`, `path`, and `locale`.
- Hash IP and user agent before storing.
- Insert into `ad_impressions` using the uniqueness constraint.
- Increment `viewsServed` only when the impression insert was new.
- Mark campaign `exhausted` inside the same transaction once `viewsServed >= packageViews`.

## Stripe Configuration

Add dependency:

```sh
bun add stripe
```

Update `.env.example`:

```txt
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
PETDEX_URL=http://localhost:3000
```

Use the existing `PETDEX_URL` convention for Checkout success/cancel URLs if available. Avoid adding a second canonical site URL unless needed.

Hosted Stripe Checkout does not require Stripe JS in the app. `next.config.ts` CSP and `Permissions-Policy: payment=()` likely do not need changes for this first version unless testing shows blocked redirects/webhooks.

## Advertise Page And Form

Create `src/app/[locale]/advertise/page.tsx`.

Use existing localized page patterns:

- `params: Promise<{ locale: string }>`.
- `setRequestLocale(locale)` if used by similar pages.
- `buildLocaleAlternates('/advertise')` in metadata.
- `SiteHeader` and `SiteFooter`.
- Semantic Tailwind tokens such as `bg-background`, `bg-surface`, `text-foreground`, and `border-border-base`.

Create `src/components/ads/advertise-form.tsx` as a client component.

Form sections:

- Company name.
- Contact email.
- Ad image upload.
- Ad title.
- Ad description.
- Destination URL.
- Package selection.
- Optional UTM fields: `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content`.
- Required legal acceptance checkbox.

Client flow:

1. Require sign-in before submission; use existing Clerk client patterns or `SubmitCTA`-style behavior.
2. Validate fields locally for quick feedback.
3. Presign and PUT the image to R2 through `/api/ads/image/presign`.
4. Create the campaign through `/api/ads`.
5. Create Checkout through `/api/ads/checkout`.
6. Redirect to Stripe Checkout.

Page copy should cover:

- Petdex reaches developers discovering and installing dev-tool-themed pets.
- This is a strong audience for AI/dev tools and subscription products like Codex-style tools.
- Ads appear subtly as sponsored cards between pet cards.
- Billing is prepaid per view.
- A view is counted only after the ad is on screen for at least 2 seconds.
- Packages and prices.
- Acceptable use policy.
- Legal notice: Petdex may delete/pause ads that do not align with policies, and those removals are not reimbursed.

## Feed Serving And UI

Modify `src/app/[locale]/page.tsx`.

- Keep the existing `searchPets({ sort: 'curated' })` behavior.
- Fetch a small set of active public ads from `src/lib/ads/queries.ts` server-side.
- Pass them to `PetGallery` as a new prop.

Modify `src/components/pet-gallery.tsx`.

- Add an `ads` prop.
- Interleave ads only at render time; do not modify the `pets` state array.
- Keep pet indexes based on pet position, not rendered-grid position.
- Place first ad after 6 pets and subsequent ads every 12 pets.
- Rotate through active ads, with a small per-render shuffle if practical.
- Keep the existing infinite-scroll sentinel after the grid.

Create `src/components/ads/ad-card.tsx`.

- Match the existing pet card visual language: rounded card, subtle border, surface background, image stage, title, description.
- Label as `Sponsored`.
- Render as an accessible outbound anchor.
- Use `target="_blank"` and `rel="noopener noreferrer sponsored"`.

Create `src/components/ads/feed-ad-slot.tsx`.

- Client wrapper for `AdCard`.
- Use `IntersectionObserver` with around `threshold: 0.5`.
- Start a 2-second timer only when at least 50% visible and `document.visibilityState === 'visible'`.
- Cancel the timer if visibility drops before 2 seconds.
- Send exactly one impression event per rendered slot with `navigator.sendBeacon` when possible, falling back to `fetch`.
- Include `campaignId`, `sessionId`, `requestId`, `visibleMs`, `path`, and `locale`.

## Navigation And i18n

Update messages:

- `src/i18n/messages/en.json`
- `src/i18n/messages/es.json`
- `src/i18n/messages/zh.json`

Add keys for:

- Header/footer `Advertise` link.
- `/advertise` metadata.
- Hero, audience, placement, packages, form labels, validation errors, acceptable use, legal notice, checkout success/cancel messages, and sponsored label.

Update navigation:

- `src/components/site-header.tsx`: add a localized `/advertise` link.
- `src/components/site-footer.tsx`: add an `/advertise` footer link.

Do not protect the public `/advertise` route in `src/proxy.ts`; enforce auth in the API routes and form submission. Only update `src/proxy.ts` if implementation adds protected advertiser account pages later.

## Mock DB And Local Dev

Update `src/lib/mock/db.ts` fixups so mock mode tolerates the new ad tables.

Optional mock seed:

- Seed one harmless active ad in mock mode only if needed for visual testing.
- Prefer no seed by default if it complicates mock bootstrap.

## Security And Policy

- All browser mutations use `requireSameOrigin(req)` except Stripe webhook.
- Campaign creation, image presign, and checkout require Clerk auth.
- Impression tracking may be anonymous but must use same-origin, rate limiting, dedupe, and active-campaign checks.
- Validate ad image URLs against the app-controlled R2 public URL; do not allow arbitrary advertiser image hosts.
- Validate destination URLs as external `https://` URLs only.
- Store hashed IP/user-agent for impression abuse analysis, not raw values.
- Do not allow client-controlled package price, package views, or campaign status.
- Do not activate campaigns from the Checkout success redirect; only the webhook activates.

Acceptable use content should prohibit:

- Malware, phishing, scams, or deceptive flows.
- Adult/sexual content.
- Hate, harassment, or discriminatory content.
- Illegal products or services.
- Impersonation.
- Misleading AI or tool claims.
- Content that conflicts with Petdex community/product policies.

## Testing And Verification

Add focused tests where practical for:

- Package resolution rejects client price/view tampering.
- Destination URL validation rejects unsafe URLs.
- UTM builder appends `utm_source=petdex` by default.
- UTM builder preserves advertiser custom UTM fields.
- Impression validation requires `visibleMs >= 2000`.
- Duplicate `requestId` does not double-count.
- Exhausted/deleted campaigns cannot receive new billable views.

Run:

```sh
bun run check
bun run i18n:check
bun test
bun run build
```

Manual local checks:

```sh
bun run dev:mock
```

Manual Stripe webhook test:

```sh
stripe listen --forward-to localhost:3000/api/stripe/webhook
```

End-to-end manual flow:

1. Visit `/advertise` signed out; confirm sales copy and pricing are visible.
2. Try to submit; confirm sign-in is required.
3. Sign in, upload image, fill ad, select package, accept terms.
4. Confirm Stripe Checkout opens with the selected price.
5. Complete test payment.
6. Confirm webhook activates campaign.
7. Visit the home feed and confirm sponsored card appears between pet cards.
8. Scroll past quickly; confirm no impression is counted.
9. Keep ad visible for 2 seconds; confirm one impression is counted.
10. Retry/refresh; confirm duplicate client event IDs do not double-count.

## Build Order

1. Add `stripe` dependency and env docs.
2. Add schema/migration and mock DB fixups.
3. Add ads package constants, validation, URL, and query helpers.
4. Add image presign and campaign creation routes.
5. Add Stripe checkout and webhook routes.
6. Build `/advertise` page and form.
7. Add active ad query to the home page.
8. Add `AdCard`, `FeedAdSlot`, and `PetGallery` interleaving.
9. Add impression route and wire client tracking.
10. Add i18n/nav/footer updates.
11. Run checks, tests, build, and manual Stripe/feed verification.
