Skip to content

Commit c54c7ba

Browse files
ralyodioclaude
andcommitted
feat: AI-scraped coupon submission
Submit form collects URL + code + discount type/value + expiry; "Fetch details" calls POST /api/scrape (lib/scrape.ts, Claude claude-opus-4-8, structured JSON output) to extract title/description/store/image from the page. POST /api/coupons auto-resolves/creates the store by domain and persists structured discount fields. Adds coupons columns discount_type, discount_value, image_url (schema.sql + idempotent migrate.mjs). Requires ANTHROPIC_API_KEY at runtime (read lazily in getClient()). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 06d0d76 commit c54c7ba

9 files changed

Lines changed: 521 additions & 199 deletions

File tree

apps/web/app/api/coupons/route.ts

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { getDb } from '@/lib/db';
3+
import type { DiscountType } from '@/lib/types';
34

45
export async function GET(req: NextRequest) {
56
const { searchParams } = new URL(req.url);
@@ -22,19 +23,106 @@ export async function GET(req: NextRequest) {
2223
}
2324
}
2425

26+
function slugify(value: string): string {
27+
return value
28+
.toLowerCase()
29+
.replace(/[^a-z0-9]+/g, '-')
30+
.replace(/(^-|-$)/g, '');
31+
}
32+
33+
/** Human-readable badge text from structured discount fields. */
34+
function formatDiscount(type: DiscountType | null, value: number | null): string | null {
35+
if (value == null || Number.isNaN(value)) return null;
36+
if (type === 'percent') return `${value}%`;
37+
if (type === 'fixed') return `$${value} off`;
38+
return null;
39+
}
40+
41+
/**
42+
* Resolve a store by website domain (preferred) or name, creating it if needed.
43+
* Returns the store id.
44+
*/
45+
async function resolveStoreId(
46+
db: ReturnType<typeof getDb>,
47+
opts: { name: string | null; website: string | null; logoCandidate: string | null }
48+
): Promise<number> {
49+
const name = opts.name?.trim() || null;
50+
51+
// Derive a stable slug: prefer the website host, fall back to the name.
52+
let slugBase = name;
53+
let website = opts.website?.trim() || null;
54+
if (website) {
55+
try {
56+
const host = new URL(website).hostname.replace(/^www\./, '');
57+
slugBase = slugBase ?? host.split('.')[0];
58+
website = `${new URL(website).protocol}//${new URL(website).hostname}`;
59+
} catch {
60+
/* keep website as-is */
61+
}
62+
}
63+
if (!slugBase) slugBase = 'unknown-store';
64+
const slug = slugify(slugBase) || 'unknown-store';
65+
66+
const existing = await db.sql`SELECT id FROM stores WHERE slug = ${slug} LIMIT 1`;
67+
if (existing.length > 0) return existing[0].id;
68+
69+
await db.sql`
70+
INSERT INTO stores (name, slug, website)
71+
VALUES (${name ?? slugBase}, ${slug}, ${website})
72+
`;
73+
const created = await db.sql`SELECT id FROM stores WHERE slug = ${slug} LIMIT 1`;
74+
return created[0].id;
75+
}
76+
2577
export async function POST(req: NextRequest) {
2678
try {
2779
const body = await req.json();
28-
const { store_id, code, title, description, discount, expiry_date, url } = body;
80+
const {
81+
url,
82+
code,
83+
discount_type,
84+
discount_value,
85+
expiry_date,
86+
// AI-scraped listing metadata
87+
title,
88+
description,
89+
store_name,
90+
store_website,
91+
image_url,
92+
} = body;
2993

30-
if (!store_id || !title) {
31-
return NextResponse.json({ error: 'store_id and title are required' }, { status: 400 });
94+
if (!url) {
95+
return NextResponse.json({ error: 'url is required' }, { status: 400 });
3296
}
97+
if (!title) {
98+
return NextResponse.json(
99+
{ error: 'title is required (scrape the URL first)' },
100+
{ status: 400 }
101+
);
102+
}
103+
104+
const type: DiscountType | null =
105+
discount_type === 'percent' || discount_type === 'fixed' ? discount_type : null;
106+
const value =
107+
discount_value === '' || discount_value == null ? null : Number(discount_value);
108+
const discount = formatDiscount(type, value);
33109

34110
const db = getDb();
111+
const storeId = await resolveStoreId(db, {
112+
name: store_name ?? null,
113+
website: store_website ?? null,
114+
logoCandidate: image_url ?? null,
115+
});
116+
35117
await db.sql`
36-
INSERT INTO coupons (store_id, code, title, description, discount, expiry_date, url)
37-
VALUES (${store_id}, ${code}, ${title}, ${description}, ${discount}, ${expiry_date}, ${url})
118+
INSERT INTO coupons (
119+
store_id, code, title, description, discount, discount_type, discount_value,
120+
expiry_date, url, image_url
121+
)
122+
VALUES (
123+
${storeId}, ${code?.trim() || null}, ${title}, ${description ?? null}, ${discount},
124+
${type}, ${value}, ${expiry_date || null}, ${url}, ${image_url ?? null}
125+
)
38126
`;
39127
return NextResponse.json({ success: true }, { status: 201 });
40128
} catch (err) {

apps/web/app/api/scrape/route.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { scrapeListing } from '@/lib/scrape';
3+
4+
// Scraping fetches an external page and calls Claude — give it room.
5+
export const maxDuration = 60;
6+
7+
export async function POST(req: NextRequest) {
8+
let url: string;
9+
try {
10+
({ url } = await req.json());
11+
} catch {
12+
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
13+
}
14+
15+
if (!url || typeof url !== 'string') {
16+
return NextResponse.json({ error: 'url is required' }, { status: 400 });
17+
}
18+
19+
let parsed: URL;
20+
try {
21+
parsed = new URL(url);
22+
} catch {
23+
return NextResponse.json({ error: 'url is not a valid URL' }, { status: 400 });
24+
}
25+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
26+
return NextResponse.json({ error: 'url must be http or https' }, { status: 400 });
27+
}
28+
29+
try {
30+
const listing = await scrapeListing(parsed.toString());
31+
return NextResponse.json(listing);
32+
} catch (err) {
33+
console.error('scrape failed:', err);
34+
const message = err instanceof Error ? err.message : 'Failed to scrape URL';
35+
return NextResponse.json({ error: message }, { status: 502 });
36+
}
37+
}

0 commit comments

Comments
 (0)