From c100f2a8f54b44f628188220682ee2924073a533 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 17 Jun 2026 02:57:36 +0000 Subject: [PATCH] feat: optional link/URL on bounties; make coupon URL optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounties: - Add optional bounties.url column (migration + addColumn). - New-bounty form has an optional "Link" field; POST stores it; detail page renders it as an external link when present. Coupons (per request — URL was required via the scrape-first flow): - url is now optional. Submit form drops the required product link and adds an "enter details manually" path (reveals the editable details without scraping). POST no longer 400s when url is absent. - Defensive addColumn for coupons.url (was only in CREATE TABLE). Co-Authored-By: Claude Opus 4.8 --- apps/web/app/api/bounties/route.ts | 5 +++-- apps/web/app/api/coupons/route.ts | 7 ++----- apps/web/app/bounties/[id]/page.tsx | 13 +++++++++++++ apps/web/app/bounties/new/page.tsx | 9 +++++++++ apps/web/app/submit/page.tsx | 22 +++++++++++++--------- apps/web/scripts/migrate.mjs | 4 ++++ 6 files changed, 44 insertions(+), 16 deletions(-) diff --git a/apps/web/app/api/bounties/route.ts b/apps/web/app/api/bounties/route.ts index 6562ebd..fdd17d0 100644 --- a/apps/web/app/api/bounties/route.ts +++ b/apps/web/app/api/bounties/route.ts @@ -30,7 +30,7 @@ export async function POST(req: NextRequest) { const did = await getSessionDid(); if (!did) return NextResponse.json({ error: 'Authentication required' }, { status: 401 }); - const { title, description, reward_usd, store_id, store_name } = await req.json(); + const { title, description, url, reward_usd, store_id, store_name } = await req.json(); if (!title?.trim()) return NextResponse.json({ error: 'Title is required' }, { status: 400 }); const reward = parseFloat(reward_usd); @@ -43,7 +43,7 @@ export async function POST(req: NextRequest) { // Insert bounty as 'open' with a non-sequential public id for URLs. await db.sql` - INSERT INTO bounties (public_id, creator_did, store_id, store_name, title, description, reward_usd, status) + INSERT INTO bounties (public_id, creator_did, store_id, store_name, title, description, url, reward_usd, status) VALUES ( ${publicId}, ${did}, @@ -51,6 +51,7 @@ export async function POST(req: NextRequest) { ${store_name?.trim() ?? null}, ${title.trim()}, ${description?.trim() ?? null}, + ${url?.trim() || null}, ${reward}, 'open' ) diff --git a/apps/web/app/api/coupons/route.ts b/apps/web/app/api/coupons/route.ts index d99fb4e..814bdf3 100644 --- a/apps/web/app/api/coupons/route.ts +++ b/apps/web/app/api/coupons/route.ts @@ -95,12 +95,9 @@ export async function POST(req: NextRequest) { image_url, } = body; - if (!url) { - return NextResponse.json({ error: 'url is required' }, { status: 400 }); - } if (!title) { return NextResponse.json( - { error: 'title is required (scrape the URL first)' }, + { error: 'title is required' }, { status: 400 } ); } @@ -125,7 +122,7 @@ export async function POST(req: NextRequest) { ) VALUES ( ${storeId}, ${code?.trim() || null}, ${title}, ${description ?? null}, ${discount}, - ${type}, ${value}, ${expiry_date || null}, ${url}, ${image_url ?? null} + ${type}, ${value}, ${expiry_date || null}, ${url?.trim() || null}, ${image_url ?? null} ) `; return NextResponse.json({ success: true }, { status: 201 }); diff --git a/apps/web/app/bounties/[id]/page.tsx b/apps/web/app/bounties/[id]/page.tsx index d4f0ba2..65bd6b6 100644 --- a/apps/web/app/bounties/[id]/page.tsx +++ b/apps/web/app/bounties/[id]/page.tsx @@ -12,6 +12,7 @@ interface Bounty { public_id: string; title: string; description: string | null; + url: string | null; reward_usd: number; status: string; store_id: number | null; @@ -103,6 +104,18 @@ export default async function BountyPage({ params }: { params: Promise<{ id: str

{bounty.description}

)} + {/* Reference link */} + {bounty.url && ( + + {bounty.url} ↗ + + )} + {/* Claimed coupon */} {bounty.coupon_code && (
diff --git a/apps/web/app/bounties/new/page.tsx b/apps/web/app/bounties/new/page.tsx index a04a803..859b433 100644 --- a/apps/web/app/bounties/new/page.tsx +++ b/apps/web/app/bounties/new/page.tsx @@ -16,6 +16,7 @@ export default function NewBountyPage() { const [form, setForm] = useState({ title: '', description: '', + url: '', reward_usd: '0.10', store_id: '', store_name: '', @@ -43,6 +44,7 @@ export default function NewBountyPage() { body: JSON.stringify({ title: form.title.trim(), description: form.description.trim() || null, + url: form.url.trim() || null, reward_usd: reward, store_id: form.store_id ? parseInt(form.store_id) : null, store_name: !form.store_id ? form.store_name.trim() : null, @@ -138,6 +140,13 @@ export default function NewBountyPage() { rows={3} className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-orange-400 resize-none" /> + set('url', e.target.value)} + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-orange-400" + />
diff --git a/apps/web/app/submit/page.tsx b/apps/web/app/submit/page.tsx index 4c163de..33e1168 100644 --- a/apps/web/app/submit/page.tsx +++ b/apps/web/app/submit/page.tsx @@ -79,12 +79,8 @@ export default function SubmitPage() { e.preventDefault(); setError(''); - if (!url.trim()) { - setError('A product URL is required.'); - return; - } if (!scraped || !scraped.title.trim()) { - setError('Fetch the listing details first (or add a title).'); + setError('Add a title (fetch it from a link, or enter details manually).'); return; } @@ -94,7 +90,7 @@ export default function SubmitPage() { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - url: url.trim(), + url: url.trim() || null, code: code.trim() || null, discount_type: discountValue.trim() ? discountType : null, discount_value: discountValue.trim() || null, @@ -137,13 +133,13 @@ export default function SubmitPage() {

Submit a Coupon

- Paste the product link and your coupon code — we'll pull the listing details for you. + Paste a product link to auto-fill the details — or enter them manually. The link is optional.

{/* Product URL + fetch */}
-

Product link

+

Product link (optional)

setUrl(e.target.value)} className={`flex-1 ${inputClass}`} - required /> + )}
{/* AI-scraped, editable preview */} diff --git a/apps/web/scripts/migrate.mjs b/apps/web/scripts/migrate.mjs index 37ae68d..3ba80b3 100644 --- a/apps/web/scripts/migrate.mjs +++ b/apps/web/scripts/migrate.mjs @@ -85,6 +85,7 @@ await db.sql` await addColumn(() => db.sql`ALTER TABLE coupons ADD COLUMN discount_type TEXT`); await addColumn(() => db.sql`ALTER TABLE coupons ADD COLUMN discount_value REAL`); await addColumn(() => db.sql`ALTER TABLE coupons ADD COLUMN image_url TEXT`); +await addColumn(() => db.sql`ALTER TABLE coupons ADD COLUMN url TEXT`); console.log(' coupons'); await db.sql` @@ -133,6 +134,7 @@ await db.sql` store_name TEXT, title TEXT NOT NULL, description TEXT, + url TEXT, reward_usd REAL NOT NULL, status TEXT NOT NULL DEFAULT 'open', payment_id TEXT, @@ -144,6 +146,8 @@ await db.sql` `; // public_id: non-sequential URL identifier (added after initial release). await addColumn(() => db.sql`ALTER TABLE bounties ADD COLUMN public_id TEXT`); +// url: optional reference link for the bounty (added after initial release). +await addColumn(() => db.sql`ALTER TABLE bounties ADD COLUMN url TEXT`); // Backfill any rows missing a public_id (existing bounties created pre-migration). const needIds = await db.sql`SELECT id FROM bounties WHERE public_id IS NULL OR public_id = ''`; for (const row of needIds) {