Skip to content

Commit acdee9b

Browse files
committed
Merge branch 'feat/ai-scraped-submit'
# Conflicts: # apps/web/app/api/coupons/route.ts
2 parents 6e4015b + c54c7ba commit acdee9b

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,6 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server';
22
import { getDb } from '@/lib/db';
33
import { getSessionDid } from '@/lib/auth';
4+
import type { DiscountType } from '@/lib/types';
45

56
export async function GET(req: NextRequest) {
67
const { searchParams } = new URL(req.url);
@@ -23,22 +24,109 @@ export async function GET(req: NextRequest) {
2324
}
2425
}
2526

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

3082
try {
3183
const body = await req.json();
32-
const { store_id, code, title, description, discount, expiry_date, url } = body;
84+
const {
85+
url,
86+
code,
87+
discount_type,
88+
discount_value,
89+
expiry_date,
90+
// AI-scraped listing metadata
91+
title,
92+
description,
93+
store_name,
94+
store_website,
95+
image_url,
96+
} = body;
3397

34-
if (!store_id || !title) {
35-
return NextResponse.json({ error: 'store_id and title are required' }, { status: 400 });
98+
if (!url) {
99+
return NextResponse.json({ error: 'url is required' }, { status: 400 });
36100
}
101+
if (!title) {
102+
return NextResponse.json(
103+
{ error: 'title is required (scrape the URL first)' },
104+
{ status: 400 }
105+
);
106+
}
107+
108+
const type: DiscountType | null =
109+
discount_type === 'percent' || discount_type === 'fixed' ? discount_type : null;
110+
const value =
111+
discount_value === '' || discount_value == null ? null : Number(discount_value);
112+
const discount = formatDiscount(type, value);
37113

38114
const db = getDb();
115+
const storeId = await resolveStoreId(db, {
116+
name: store_name ?? null,
117+
website: store_website ?? null,
118+
logoCandidate: image_url ?? null,
119+
});
120+
39121
await db.sql`
40-
INSERT INTO coupons (store_id, code, title, description, discount, expiry_date, url)
41-
VALUES (${store_id}, ${code}, ${title}, ${description}, ${discount}, ${expiry_date}, ${url})
122+
INSERT INTO coupons (
123+
store_id, code, title, description, discount, discount_type, discount_value,
124+
expiry_date, url, image_url
125+
)
126+
VALUES (
127+
${storeId}, ${code?.trim() || null}, ${title}, ${description ?? null}, ${discount},
128+
${type}, ${value}, ${expiry_date || null}, ${url}, ${image_url ?? null}
129+
)
42130
`;
43131
return NextResponse.json({ success: true }, { status: 201 });
44132
} 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)