Initial WinningHunter MVP
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
DATABASE_URL="postgresql://ht44@localhost:5432/winninghunter?schema=public"
|
||||
AUTH_COOKIE_NAME=wh_session
|
||||
AUTH_SESSION_DAYS=30
|
||||
ADMIN_EMAILS="admin@winninghunter.local"
|
||||
@@ -0,0 +1,9 @@
|
||||
NEXT_PUBLIC_APP_URL=https://your-domain.com
|
||||
DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/winninghunter?schema=public"
|
||||
AUTH_COOKIE_NAME=wh_session
|
||||
AUTH_SESSION_DAYS=30
|
||||
ADMIN_EMAILS="admin@winninghunter.local"
|
||||
|
||||
# For docker-compose
|
||||
POSTGRES_PASSWORD="change-this-long-random-password"
|
||||
PORT=3000
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
next-env.d.ts
|
||||
/prisma/dev.db
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/package-lock.json ./package-lock.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/next.config.mjs ./next.config.mjs
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -0,0 +1,108 @@
|
||||
# WinningHunter MVP
|
||||
|
||||
WinningHunter klonu için çalışan MVP:
|
||||
|
||||
- Next.js 16 + React 19
|
||||
- Prisma + PostgreSQL
|
||||
- Email/password auth
|
||||
- Plan/subscription/kota altyapısı
|
||||
- Meta Ads search
|
||||
- Store explorer + store detail
|
||||
- Store tracker
|
||||
- Saved ads/folders
|
||||
- Pricing
|
||||
- Admin data panel
|
||||
|
||||
## Lokal kurulum
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
createdb winninghunter
|
||||
npm run db:push
|
||||
npm run db:seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Demo hesaplar:
|
||||
|
||||
```txt
|
||||
Demo:
|
||||
demo@winninghunter.local / demo1234
|
||||
|
||||
Admin:
|
||||
admin@winninghunter.local / admin1234
|
||||
```
|
||||
|
||||
## Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
PORT=3001 npm run start
|
||||
```
|
||||
|
||||
Doğrulama:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3001/
|
||||
curl http://localhost:3001/login
|
||||
```
|
||||
|
||||
## Docker ile çalıştırma
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env
|
||||
docker compose up -d --build
|
||||
docker compose exec app npm run db:seed
|
||||
```
|
||||
|
||||
## Coolify deploy
|
||||
|
||||
1. Yeni proje oluştur.
|
||||
2. Git repo veya ZIP kaynak olarak bu klasörü bağla.
|
||||
3. Build type: Dockerfile.
|
||||
4. Domain: `winninghunter.your-domain.com`.
|
||||
5. Env:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_APP_URL=https://winninghunter.your-domain.com
|
||||
DATABASE_URL=postgresql://...
|
||||
AUTH_COOKIE_NAME=wh_session
|
||||
AUTH_SESSION_DAYS=30
|
||||
ADMIN_EMAILS=admin@winninghunter.local
|
||||
```
|
||||
|
||||
6. İlk deploy sonrası terminal:
|
||||
|
||||
```bash
|
||||
npx prisma db push
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
## Vercel deploy
|
||||
|
||||
1. Vercel projesi oluştur.
|
||||
2. PostgreSQL sağlayıcısı bağla: Neon/Supabase/Vercel Postgres.
|
||||
3. `DATABASE_URL` ve diğer env değerlerini gir.
|
||||
4. Build command:
|
||||
|
||||
```bash
|
||||
prisma generate && next build
|
||||
```
|
||||
|
||||
5. İlk deploy sonrası lokalden:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="production-url" npx prisma db push
|
||||
DATABASE_URL="production-url" npm run db:seed
|
||||
```
|
||||
|
||||
## Kalan V1 işleri
|
||||
|
||||
- Brand Tracker UI ve AI tagging job worker
|
||||
- Magic AI embedding search
|
||||
- TikTok Shop Explorer
|
||||
- Trends
|
||||
- MCP server/API credit dashboard
|
||||
- Stripe checkout gerçek entegrasyonu
|
||||
- OpenSearch/pg_trgm ile gelişmiş arama
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: winninghunter
|
||||
POSTGRES_USER: winninghunter
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-winninghunter_dev_password}
|
||||
volumes:
|
||||
- winninghunter_pg:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U winninghunter -d winninghunter"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 8
|
||||
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000}
|
||||
DATABASE_URL: postgresql://winninghunter:${POSTGRES_PASSWORD:-winninghunter_dev_password}@db:5432/winninghunter?schema=public
|
||||
AUTH_COOKIE_NAME: wh_session
|
||||
AUTH_SESSION_DAYS: 30
|
||||
ADMIN_EMAILS: admin@winninghunter.local
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
command: sh -c "npx prisma db push && npm run start"
|
||||
|
||||
volumes:
|
||||
winninghunter_pg:
|
||||
@@ -0,0 +1,10 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
typescript: { ignoreBuildErrors: false },
|
||||
turbopack: {
|
||||
root: process.cwd()
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+2736
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "winninghunter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "5.22.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"next": "^16.3.0-canary.79",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"zod": "3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.4.47",
|
||||
"prisma": "5.22.0",
|
||||
"tailwindcss": "3.4.13",
|
||||
"tsx": "4.19.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,563 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
USER
|
||||
ADMIN
|
||||
}
|
||||
|
||||
enum PlanCode {
|
||||
FREE
|
||||
BASIC
|
||||
STANDARD
|
||||
PREMIUM
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
ACTIVE
|
||||
TRIALING
|
||||
PAST_DUE
|
||||
CANCELED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
enum UsagePeriod {
|
||||
DAILY
|
||||
MONTHLY
|
||||
LIFETIME
|
||||
}
|
||||
|
||||
enum AdSource {
|
||||
META
|
||||
ADSPY
|
||||
PINTEREST
|
||||
TIKTOK
|
||||
}
|
||||
|
||||
enum AdStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum MediaType {
|
||||
VIDEO
|
||||
IMAGE
|
||||
CAROUSEL
|
||||
DCT
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum AdScore {
|
||||
ESTABLISHED
|
||||
HAS_POTENTIAL
|
||||
UNESTABLISHED
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum StorePlatform {
|
||||
SHOPIFY
|
||||
WOOCOMMERCE
|
||||
CUSTOM
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
QUEUED
|
||||
PROCESSING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum IngestStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String?
|
||||
name String?
|
||||
avatarUrl String?
|
||||
role UserRole @default(USER)
|
||||
locale String @default("tr")
|
||||
currency String @default("EUR")
|
||||
theme String @default("system")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastLoginAt DateTime?
|
||||
|
||||
sessions AuthSession[]
|
||||
subscription Subscription?
|
||||
usageCounters UsageCounter[]
|
||||
savedFolders SavedFolder[]
|
||||
savedAds SavedAd[]
|
||||
filterPresets FilterPreset[]
|
||||
trackedStores TrackedStore[]
|
||||
exportJobs ExportJob[]
|
||||
apiKeys ApiKey[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model AuthSession {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
model Plan {
|
||||
id String @id @default(cuid())
|
||||
code PlanCode @unique
|
||||
name String
|
||||
monthlyPriceEur Int
|
||||
quarterlyPriceEur Int?
|
||||
yearlyPriceEur Int?
|
||||
description String?
|
||||
features Json
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
subscriptions Subscription[]
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
planId String
|
||||
status SubscriptionStatus @default(ACTIVE)
|
||||
billingInterval String @default("monthly")
|
||||
currentPeriodStart DateTime?
|
||||
currentPeriodEnd DateTime?
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
provider String?
|
||||
providerCustomerId String?
|
||||
providerSubscriptionId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
plan Plan @relation(fields: [planId], references: [id])
|
||||
|
||||
@@index([planId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model UsageCounter {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
metric String
|
||||
period UsagePeriod
|
||||
periodKey String
|
||||
used Int @default(0)
|
||||
limit Int?
|
||||
resetAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, metric, periodKey])
|
||||
@@index([userId, metric])
|
||||
@@index([periodKey])
|
||||
}
|
||||
|
||||
model BrandPage {
|
||||
id String @id @default(cuid())
|
||||
source AdSource @default(META)
|
||||
externalPageId String?
|
||||
name String
|
||||
handle String?
|
||||
logoUrl String?
|
||||
pageUrl String?
|
||||
websiteDomain String?
|
||||
fbLikes Int?
|
||||
igFollowers Int?
|
||||
country String?
|
||||
niche String?
|
||||
storeId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull)
|
||||
ads Ad[]
|
||||
|
||||
@@unique([source, externalPageId])
|
||||
@@index([websiteDomain])
|
||||
@@index([niche])
|
||||
@@index([country])
|
||||
}
|
||||
|
||||
model Ad {
|
||||
id String @id @default(cuid())
|
||||
source AdSource @default(META)
|
||||
externalAdId String?
|
||||
brandPageId String?
|
||||
status AdStatus @default(UNKNOWN)
|
||||
mediaType MediaType @default(UNKNOWN)
|
||||
adScore AdScore @default(UNKNOWN)
|
||||
primaryText String?
|
||||
headline String?
|
||||
description String?
|
||||
ctaText String?
|
||||
landingUrl String?
|
||||
productUrl String?
|
||||
language String?
|
||||
countries String[] @default([])
|
||||
niche String?
|
||||
firstSeenAt DateTime?
|
||||
lastSeenAt DateTime?
|
||||
createdAtSource DateTime?
|
||||
daysRunning Int?
|
||||
estimatedReachMin Int?
|
||||
estimatedReachMax Int?
|
||||
estimatedSpendMin Int?
|
||||
estimatedSpendMax Int?
|
||||
estimatedCpm Float?
|
||||
rank Float?
|
||||
rankPercentile Float?
|
||||
rankGrowth String?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
brandPage BrandPage? @relation(fields: [brandPageId], references: [id], onDelete: SetNull)
|
||||
creatives AdCreative[]
|
||||
metricsDaily AdMetricDaily[]
|
||||
countryStats AdCountryStat[]
|
||||
aiTags AITag[]
|
||||
savedBy SavedAd[]
|
||||
|
||||
@@unique([source, externalAdId])
|
||||
@@index([source, status])
|
||||
@@index([mediaType])
|
||||
@@index([niche])
|
||||
@@index([language])
|
||||
@@index([firstSeenAt])
|
||||
@@index([lastSeenAt])
|
||||
@@index([daysRunning])
|
||||
@@index([rankPercentile])
|
||||
}
|
||||
|
||||
model AdCreative {
|
||||
id String @id @default(cuid())
|
||||
adId String
|
||||
type MediaType
|
||||
url String
|
||||
thumbnailUrl String?
|
||||
width Int?
|
||||
height Int?
|
||||
durationSeconds Int?
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ad Ad @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([adId])
|
||||
@@index([type])
|
||||
}
|
||||
|
||||
model AdMetricDaily {
|
||||
id String @id @default(cuid())
|
||||
adId String
|
||||
date DateTime
|
||||
reachMin Int?
|
||||
reachMax Int?
|
||||
spendMin Int?
|
||||
spendMax Int?
|
||||
cpm Float?
|
||||
rank Float?
|
||||
rankPercentile Float?
|
||||
activeCountries Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ad Ad @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([adId, date])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model AdCountryStat {
|
||||
id String @id @default(cuid())
|
||||
adId String
|
||||
countryCode String
|
||||
reachMin Int?
|
||||
reachMax Int?
|
||||
spendMin Int?
|
||||
spendMax Int?
|
||||
sharePercent Float?
|
||||
|
||||
ad Ad @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([adId, countryCode])
|
||||
@@index([countryCode])
|
||||
}
|
||||
|
||||
model AITag {
|
||||
id String @id @default(cuid())
|
||||
adId String
|
||||
type String
|
||||
value String
|
||||
confidence Float?
|
||||
explanation String?
|
||||
model String?
|
||||
promptVersion String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
ad Ad @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([adId])
|
||||
@@index([type, value])
|
||||
}
|
||||
|
||||
model Store {
|
||||
id String @id @default(cuid())
|
||||
domain String @unique
|
||||
name String
|
||||
logoUrl String?
|
||||
platform StorePlatform @default(UNKNOWN)
|
||||
country String?
|
||||
currency String?
|
||||
language String?
|
||||
niche String?
|
||||
categoryPath String[] @default([])
|
||||
shopUrl String?
|
||||
createdAtSource DateTime?
|
||||
dataSince DateTime?
|
||||
shopifyTheme String?
|
||||
productCount Int?
|
||||
collectionCount Int?
|
||||
trustpilotScore Float?
|
||||
trustpilotReviewCount Int?
|
||||
monthlyVisits Int?
|
||||
monthlyVisitGrowth Float?
|
||||
estRevenue30dMin Int?
|
||||
estRevenue30dMax Int?
|
||||
estRevenue24hMin Int?
|
||||
estRevenue24hMax Int?
|
||||
trafficCountries Json?
|
||||
socials Json?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
brandPages BrandPage[]
|
||||
products StoreProduct[]
|
||||
snapshots StoreSnapshot[]
|
||||
pixels StorePixel[]
|
||||
apps StoreApp[]
|
||||
trackedBy TrackedStore[]
|
||||
|
||||
@@index([niche])
|
||||
@@index([country])
|
||||
@@index([currency])
|
||||
@@index([monthlyVisits])
|
||||
@@index([monthlyVisitGrowth])
|
||||
@@index([estRevenue30dMax])
|
||||
}
|
||||
|
||||
model StoreSnapshot {
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
date DateTime
|
||||
monthlyVisits Int?
|
||||
estRevenueMin Int?
|
||||
estRevenueMax Int?
|
||||
activeAdsCount Int?
|
||||
productCount Int?
|
||||
trafficCountries Json?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([storeId, date])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
model StoreProduct {
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
externalProductId String?
|
||||
title String
|
||||
handle String?
|
||||
imageUrl String?
|
||||
productUrl String?
|
||||
price Float?
|
||||
compareAtPrice Float?
|
||||
currency String?
|
||||
variantCount Int?
|
||||
isBestSeller Boolean @default(false)
|
||||
firstSeenAt DateTime?
|
||||
raw Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([storeId])
|
||||
@@index([isBestSeller])
|
||||
@@index([price])
|
||||
}
|
||||
|
||||
model StorePixel {
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
type String
|
||||
value String?
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([storeId, type, value])
|
||||
@@index([type])
|
||||
}
|
||||
|
||||
model StoreApp {
|
||||
id String @id @default(cuid())
|
||||
storeId String
|
||||
name String
|
||||
category String?
|
||||
detectedAt DateTime @default(now())
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([storeId, name])
|
||||
@@index([name])
|
||||
}
|
||||
|
||||
model SavedFolder {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
name String
|
||||
color String?
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
savedAds SavedAd[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model SavedAd {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
adId String
|
||||
folderId String?
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
ad Ad @relation(fields: [adId], references: [id], onDelete: Cascade)
|
||||
folder SavedFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([userId, adId])
|
||||
@@index([userId, folderId])
|
||||
@@index([adId])
|
||||
}
|
||||
|
||||
model TrackedStore {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
storeId String
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, storeId])
|
||||
@@index([userId])
|
||||
@@index([storeId])
|
||||
}
|
||||
|
||||
model FilterPreset {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
module String
|
||||
name String
|
||||
filters Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, module, name])
|
||||
@@index([userId, module])
|
||||
}
|
||||
|
||||
model ExportJob {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
module String
|
||||
filters Json?
|
||||
status ExportStatus @default(QUEUED)
|
||||
fileUrl String?
|
||||
errorMessage String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([module])
|
||||
}
|
||||
|
||||
model IngestJob {
|
||||
id String @id @default(cuid())
|
||||
source String
|
||||
type String
|
||||
status IngestStatus @default(QUEUED)
|
||||
startedAt DateTime?
|
||||
finishedAt DateTime?
|
||||
recordsImported Int @default(0)
|
||||
recordsFailed Int @default(0)
|
||||
errorMessage String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([source, status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model ApiKey {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
name String
|
||||
keyHash String @unique
|
||||
prefix String
|
||||
lastUsedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
monthlyCreditLimit Int?
|
||||
usedCreditsThisMonth Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([prefix])
|
||||
}
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import {
|
||||
AdScore,
|
||||
AdSource,
|
||||
AdStatus,
|
||||
MediaType,
|
||||
PlanCode,
|
||||
PrismaClient,
|
||||
StorePlatform,
|
||||
UsagePeriod,
|
||||
UserRole
|
||||
} from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const planFeatures = {
|
||||
FREE: {
|
||||
adsSearchDaily: 10,
|
||||
storesSearchDaily: null,
|
||||
tiktokSearchDaily: 0,
|
||||
trendsSearchDaily: 5,
|
||||
apiCreditsMonthly: 0,
|
||||
trackedStores: 0,
|
||||
followedBrands: 0,
|
||||
savedAds: 20,
|
||||
advancedFilters: false,
|
||||
lockedResults: true,
|
||||
exportEnabled: false
|
||||
},
|
||||
BASIC: {
|
||||
adsSearchDaily: 100,
|
||||
storesSearchDaily: null,
|
||||
tiktokSearchDaily: 200,
|
||||
trendsSearchDaily: 20,
|
||||
apiCreditsMonthly: 100,
|
||||
trackedStores: 25,
|
||||
followedBrands: 2,
|
||||
savedAds: 500,
|
||||
advancedFilters: false,
|
||||
lockedResults: false,
|
||||
exportEnabled: true
|
||||
},
|
||||
STANDARD: {
|
||||
adsSearchDaily: 500,
|
||||
storesSearchDaily: null,
|
||||
tiktokSearchDaily: 1000,
|
||||
trendsSearchDaily: 100,
|
||||
apiCreditsMonthly: 20000,
|
||||
trackedStores: 50,
|
||||
followedBrands: 30,
|
||||
savedAds: 2000,
|
||||
advancedFilters: true,
|
||||
lockedResults: false,
|
||||
exportEnabled: true,
|
||||
pinterestAds: true,
|
||||
tiktokAds: true
|
||||
},
|
||||
PREMIUM: {
|
||||
adsSearchDaily: 2000,
|
||||
storesSearchDaily: null,
|
||||
tiktokSearchDaily: null,
|
||||
trendsSearchDaily: null,
|
||||
apiCreditsMonthly: 20000,
|
||||
trackedStores: 500,
|
||||
followedBrands: 200,
|
||||
savedAds: null,
|
||||
advancedFilters: true,
|
||||
lockedResults: false,
|
||||
exportEnabled: true,
|
||||
weeklyResearchCall: true,
|
||||
pinterestAds: true,
|
||||
tiktokAds: true
|
||||
}
|
||||
};
|
||||
|
||||
const stores = [
|
||||
{
|
||||
domain: "petpro-demo.com",
|
||||
name: "PetPro Demo",
|
||||
logoUrl: "https://placehold.co/96x96/7c3aed/ffffff?text=PP",
|
||||
country: "US",
|
||||
currency: "USD",
|
||||
language: "en",
|
||||
niche: "Pets",
|
||||
categoryPath: ["Pets", "Dog Accessories"],
|
||||
theme: "Dawn",
|
||||
visits: 120000,
|
||||
growth: 18.2,
|
||||
revMin: 45000,
|
||||
revMax: 90000,
|
||||
products: [
|
||||
["Smart Dog Collar", 29.99, true],
|
||||
["No Pull Dog Harness", 34.99, true],
|
||||
["Travel Pet Bottle", 19.99, false]
|
||||
],
|
||||
pixels: ["Meta Pixel", "TikTok Pixel", "Google Analytics"],
|
||||
apps: ["Klaviyo", "Judge.me", "ReConvert"]
|
||||
},
|
||||
{
|
||||
domain: "glowly-demo.com",
|
||||
name: "Glowly Demo",
|
||||
logoUrl: "https://placehold.co/96x96/ec4899/ffffff?text=GL",
|
||||
country: "GB",
|
||||
currency: "GBP",
|
||||
language: "en",
|
||||
niche: "Beauty",
|
||||
categoryPath: ["Beauty", "Skincare"],
|
||||
theme: "Prestige",
|
||||
visits: 245000,
|
||||
growth: 27.5,
|
||||
revMin: 90000,
|
||||
revMax: 180000,
|
||||
products: [
|
||||
["LED Face Sculptor", 69.99, true],
|
||||
["Hydrating Serum Kit", 39.99, false],
|
||||
["Ice Roller Pro", 22.99, true]
|
||||
],
|
||||
pixels: ["Meta Pixel", "Pinterest Tag", "Google Analytics"],
|
||||
apps: ["Klaviyo", "Loox", "Recharge"]
|
||||
},
|
||||
{
|
||||
domain: "fitfuel-demo.com",
|
||||
name: "FitFuel Demo",
|
||||
logoUrl: "https://placehold.co/96x96/16a34a/ffffff?text=FF",
|
||||
country: "US",
|
||||
currency: "USD",
|
||||
language: "en",
|
||||
niche: "Supplements",
|
||||
categoryPath: ["Health", "Supplements"],
|
||||
theme: "Impulse",
|
||||
visits: 310000,
|
||||
growth: 9.4,
|
||||
revMin: 130000,
|
||||
revMax: 260000,
|
||||
products: [
|
||||
["Greens Energy Blend", 49.99, true],
|
||||
["Sleep Recovery Gummies", 24.99, true],
|
||||
["Protein Coffee", 39.99, false]
|
||||
],
|
||||
pixels: ["Meta Pixel", "TikTok Pixel", "Google Analytics"],
|
||||
apps: ["Klaviyo", "Recharge", "Yotpo"]
|
||||
},
|
||||
{
|
||||
domain: "homezen-demo.com",
|
||||
name: "HomeZen Demo",
|
||||
logoUrl: "https://placehold.co/96x96/0ea5e9/ffffff?text=HZ",
|
||||
country: "DE",
|
||||
currency: "EUR",
|
||||
language: "de",
|
||||
niche: "Household",
|
||||
categoryPath: ["Household", "Home Gadgets"],
|
||||
theme: "Refresh",
|
||||
visits: 87000,
|
||||
growth: 34.8,
|
||||
revMin: 30000,
|
||||
revMax: 70000,
|
||||
products: [
|
||||
["Magnetic Window Cleaner", 24.99, true],
|
||||
["Foldable Storage Rack", 44.99, false],
|
||||
["Mini Desk Vacuum", 17.99, true]
|
||||
],
|
||||
pixels: ["Meta Pixel", "Google Analytics"],
|
||||
apps: ["Judge.me", "Klaviyo"]
|
||||
}
|
||||
];
|
||||
|
||||
const adCopy = [
|
||||
{
|
||||
domain: "petpro-demo.com",
|
||||
headline: "Smart Dog Collar",
|
||||
text: "Your dog deserves a safer walk. Meet the smart collar loved by 20,000+ pet parents.",
|
||||
media: MediaType.VIDEO,
|
||||
score: AdScore.ESTABLISHED,
|
||||
countries: ["US", "GB", "CA"],
|
||||
days: 27,
|
||||
rank: 8,
|
||||
niche: "Pets"
|
||||
},
|
||||
{
|
||||
domain: "petpro-demo.com",
|
||||
headline: "No Pull Dog Harness",
|
||||
text: "No more pulling. Give your dog freedom while staying in control.",
|
||||
media: MediaType.IMAGE,
|
||||
score: AdScore.HAS_POTENTIAL,
|
||||
countries: ["US"],
|
||||
days: 9,
|
||||
rank: 19,
|
||||
niche: "Pets"
|
||||
},
|
||||
{
|
||||
domain: "glowly-demo.com",
|
||||
headline: "LED Face Sculptor",
|
||||
text: "Spa-level glow at home. See why creators are switching to LED sculpting.",
|
||||
media: MediaType.VIDEO,
|
||||
score: AdScore.ESTABLISHED,
|
||||
countries: ["GB", "US", "AU"],
|
||||
days: 42,
|
||||
rank: 4,
|
||||
niche: "Beauty"
|
||||
},
|
||||
{
|
||||
domain: "glowly-demo.com",
|
||||
headline: "Hydrating Serum Kit",
|
||||
text: "Dry skin? Build a 3-step hydration routine that actually feels lightweight.",
|
||||
media: MediaType.CAROUSEL,
|
||||
score: AdScore.HAS_POTENTIAL,
|
||||
countries: ["GB"],
|
||||
days: 15,
|
||||
rank: 16,
|
||||
niche: "Beauty"
|
||||
},
|
||||
{
|
||||
domain: "fitfuel-demo.com",
|
||||
headline: "Greens Energy Blend",
|
||||
text: "Your morning coffee is not enough. Get clean energy with greens, adaptogens, and no crash.",
|
||||
media: MediaType.VIDEO,
|
||||
score: AdScore.ESTABLISHED,
|
||||
countries: ["US", "CA"],
|
||||
days: 61,
|
||||
rank: 2,
|
||||
niche: "Supplements"
|
||||
},
|
||||
{
|
||||
domain: "fitfuel-demo.com",
|
||||
headline: "Sleep Recovery Gummies",
|
||||
text: "Wake up rested. Sleep Recovery Gummies support your nightly wind-down routine.",
|
||||
media: MediaType.IMAGE,
|
||||
score: AdScore.HAS_POTENTIAL,
|
||||
countries: ["US"],
|
||||
days: 6,
|
||||
rank: 26,
|
||||
niche: "Supplements"
|
||||
},
|
||||
{
|
||||
domain: "homezen-demo.com",
|
||||
headline: "Magnetic Window Cleaner",
|
||||
text: "Clean both sides of your window from inside. The gadget going viral in Europe.",
|
||||
media: MediaType.VIDEO,
|
||||
score: AdScore.HAS_POTENTIAL,
|
||||
countries: ["DE", "AT", "CH"],
|
||||
days: 18,
|
||||
rank: 12,
|
||||
niche: "Household"
|
||||
},
|
||||
{
|
||||
domain: "homezen-demo.com",
|
||||
headline: "Foldable Storage Rack",
|
||||
text: "Small home? Create more space with a foldable storage rack.",
|
||||
media: MediaType.IMAGE,
|
||||
score: AdScore.UNESTABLISHED,
|
||||
countries: ["DE"],
|
||||
days: 3,
|
||||
rank: 49,
|
||||
niche: "Household",
|
||||
inactive: true
|
||||
}
|
||||
];
|
||||
|
||||
function daysAgo(days: number) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding WinningHunter...");
|
||||
|
||||
const free = await prisma.plan.upsert({
|
||||
where: { code: PlanCode.FREE },
|
||||
update: { features: planFeatures.FREE },
|
||||
create: { code: PlanCode.FREE, name: "Free", monthlyPriceEur: 0, quarterlyPriceEur: 0, yearlyPriceEur: 0, features: planFeatures.FREE, sortOrder: 0 }
|
||||
});
|
||||
const basic = await prisma.plan.upsert({
|
||||
where: { code: PlanCode.BASIC },
|
||||
update: { features: planFeatures.BASIC },
|
||||
create: { code: PlanCode.BASIC, name: "Basic", monthlyPriceEur: 42, quarterlyPriceEur: 107, yearlyPriceEur: 302, features: planFeatures.BASIC, sortOrder: 1 }
|
||||
});
|
||||
await prisma.plan.upsert({
|
||||
where: { code: PlanCode.STANDARD },
|
||||
update: { features: planFeatures.STANDARD },
|
||||
create: { code: PlanCode.STANDARD, name: "Standard", monthlyPriceEur: 68, quarterlyPriceEur: 173, yearlyPriceEur: 490, features: planFeatures.STANDARD, sortOrder: 2 }
|
||||
});
|
||||
await prisma.plan.upsert({
|
||||
where: { code: PlanCode.PREMIUM },
|
||||
update: { features: planFeatures.PREMIUM },
|
||||
create: { code: PlanCode.PREMIUM, name: "Premium", monthlyPriceEur: 212, quarterlyPriceEur: 541, yearlyPriceEur: 1526, features: planFeatures.PREMIUM, sortOrder: 3 }
|
||||
});
|
||||
|
||||
const demo = await prisma.user.upsert({
|
||||
where: { email: "demo@winninghunter.local" },
|
||||
update: { passwordHash: await bcrypt.hash("demo1234", 10), name: "Demo User" },
|
||||
create: {
|
||||
email: "demo@winninghunter.local",
|
||||
name: "Demo User",
|
||||
passwordHash: await bcrypt.hash("demo1234", 10),
|
||||
role: UserRole.USER,
|
||||
subscription: { create: { planId: free.id, status: "ACTIVE" } }
|
||||
}
|
||||
});
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: "admin@winninghunter.local" },
|
||||
update: { passwordHash: await bcrypt.hash("admin1234", 10), name: "Admin", role: UserRole.ADMIN },
|
||||
create: {
|
||||
email: "admin@winninghunter.local",
|
||||
name: "Admin",
|
||||
passwordHash: await bcrypt.hash("admin1234", 10),
|
||||
role: UserRole.ADMIN,
|
||||
subscription: { create: { planId: basic.id, status: "ACTIVE" } }
|
||||
}
|
||||
});
|
||||
|
||||
for (const s of stores) {
|
||||
const store = await prisma.store.upsert({
|
||||
where: { domain: s.domain },
|
||||
update: {
|
||||
name: s.name,
|
||||
logoUrl: s.logoUrl,
|
||||
country: s.country,
|
||||
currency: s.currency,
|
||||
language: s.language,
|
||||
niche: s.niche,
|
||||
categoryPath: s.categoryPath,
|
||||
shopifyTheme: s.theme,
|
||||
monthlyVisits: s.visits,
|
||||
monthlyVisitGrowth: s.growth,
|
||||
estRevenue30dMin: s.revMin,
|
||||
estRevenue30dMax: s.revMax
|
||||
},
|
||||
create: {
|
||||
domain: s.domain,
|
||||
name: s.name,
|
||||
logoUrl: s.logoUrl,
|
||||
platform: StorePlatform.SHOPIFY,
|
||||
country: s.country,
|
||||
currency: s.currency,
|
||||
language: s.language,
|
||||
niche: s.niche,
|
||||
categoryPath: s.categoryPath,
|
||||
shopUrl: `https://${s.domain}`,
|
||||
createdAtSource: daysAgo(720),
|
||||
dataSince: daysAgo(180),
|
||||
shopifyTheme: s.theme,
|
||||
productCount: s.products.length,
|
||||
collectionCount: 8,
|
||||
trustpilotScore: 4.2 + Math.random() * 0.5,
|
||||
trustpilotReviewCount: Math.floor(400 + Math.random() * 2000),
|
||||
monthlyVisits: s.visits,
|
||||
monthlyVisitGrowth: s.growth,
|
||||
estRevenue30dMin: s.revMin,
|
||||
estRevenue30dMax: s.revMax,
|
||||
estRevenue24hMin: Math.round(s.revMin / 30),
|
||||
estRevenue24hMax: Math.round(s.revMax / 30),
|
||||
trafficCountries: { [s.country]: 68, US: 18, GB: 8, CA: 6 },
|
||||
socials: { instagram: `https://instagram.com/${s.name.toLowerCase().replaceAll(" ", "")}` }
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.brandPage.upsert({
|
||||
where: { source_externalPageId: { source: AdSource.META, externalPageId: `page_${s.domain}` } },
|
||||
update: { storeId: store.id, name: s.name, logoUrl: s.logoUrl, websiteDomain: s.domain },
|
||||
create: {
|
||||
source: AdSource.META,
|
||||
externalPageId: `page_${s.domain}`,
|
||||
name: s.name,
|
||||
logoUrl: s.logoUrl,
|
||||
websiteDomain: s.domain,
|
||||
pageUrl: `https://facebook.com/${s.domain.replaceAll(".", "")}`,
|
||||
fbLikes: Math.round(s.visits / 8),
|
||||
igFollowers: Math.round(s.visits / 5),
|
||||
country: s.country,
|
||||
niche: s.niche,
|
||||
storeId: store.id
|
||||
}
|
||||
});
|
||||
|
||||
for (const [title, price, best] of s.products) {
|
||||
const handle = String(title).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
await prisma.storeProduct.upsert({
|
||||
where: { id: `${store.id}_${handle}` },
|
||||
update: { title: String(title), price: Number(price), isBestSeller: Boolean(best) },
|
||||
create: {
|
||||
id: `${store.id}_${handle}`,
|
||||
storeId: store.id,
|
||||
title: String(title),
|
||||
handle,
|
||||
imageUrl: `https://placehold.co/320x240/f1f5f9/0f172a?text=${encodeURIComponent(String(title))}`,
|
||||
productUrl: `https://${s.domain}/products/${handle}`,
|
||||
price: Number(price),
|
||||
compareAtPrice: Number(price) * 1.6,
|
||||
currency: s.currency,
|
||||
variantCount: 3,
|
||||
isBestSeller: Boolean(best),
|
||||
firstSeenAt: daysAgo(90)
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const pixel of s.pixels) {
|
||||
await prisma.storePixel.upsert({ where: { storeId_type_value: { storeId: store.id, type: pixel, value: "" } }, update: {}, create: { storeId: store.id, type: pixel, value: "" } });
|
||||
}
|
||||
for (const app of s.apps) {
|
||||
await prisma.storeApp.upsert({ where: { storeId_name: { storeId: store.id, name: app } }, update: {}, create: { storeId: store.id, name: app, category: "Marketing" } });
|
||||
}
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const date = daysAgo(i * 30);
|
||||
await prisma.storeSnapshot.upsert({
|
||||
where: { storeId_date: { storeId: store.id, date } },
|
||||
update: {},
|
||||
create: { storeId: store.id, date, monthlyVisits: Math.round(s.visits * (1 - i * 0.06)), estRevenueMin: Math.round(s.revMin * (1 - i * 0.05)), estRevenueMax: Math.round(s.revMax * (1 - i * 0.05)), activeAdsCount: Math.max(2, 22 - i * 2), productCount: s.products.length }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let adIndex = 1;
|
||||
for (const a of adCopy) {
|
||||
const page = await prisma.brandPage.findFirstOrThrow({ where: { websiteDomain: a.domain } });
|
||||
const ad = await prisma.ad.upsert({
|
||||
where: { source_externalAdId: { source: AdSource.META, externalAdId: `demo_ad_${adIndex}` } },
|
||||
update: {},
|
||||
create: {
|
||||
source: AdSource.META,
|
||||
externalAdId: `demo_ad_${adIndex}`,
|
||||
brandPageId: page.id,
|
||||
status: a.inactive ? AdStatus.INACTIVE : AdStatus.ACTIVE,
|
||||
mediaType: a.media,
|
||||
adScore: a.score,
|
||||
primaryText: a.text,
|
||||
headline: a.headline,
|
||||
description: "Demo competitive intelligence ad.",
|
||||
ctaText: "Shop Now",
|
||||
landingUrl: `https://${a.domain}`,
|
||||
productUrl: `https://${a.domain}/products/${a.headline.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`,
|
||||
language: a.domain.includes("homezen") ? "de" : "en",
|
||||
countries: a.countries,
|
||||
niche: a.niche,
|
||||
firstSeenAt: daysAgo(a.days),
|
||||
lastSeenAt: a.inactive ? daysAgo(5) : new Date(),
|
||||
createdAtSource: daysAgo(a.days),
|
||||
daysRunning: a.days,
|
||||
estimatedReachMin: 10000 * adIndex,
|
||||
estimatedReachMax: 40000 * adIndex,
|
||||
estimatedSpendMin: 500 * adIndex,
|
||||
estimatedSpendMax: 1600 * adIndex,
|
||||
estimatedCpm: 12 + adIndex,
|
||||
rank: 100 - a.rank,
|
||||
rankPercentile: a.rank,
|
||||
rankGrowth: a.rank < 20 ? "Rising" : "Stable"
|
||||
}
|
||||
});
|
||||
await prisma.adCreative.create({
|
||||
data: {
|
||||
adId: ad.id,
|
||||
type: a.media,
|
||||
url: `https://placehold.co/640x480/111827/ffffff?text=${encodeURIComponent(a.headline)}`,
|
||||
thumbnailUrl: `https://placehold.co/640x480/111827/ffffff?text=${encodeURIComponent(a.headline)}`,
|
||||
durationSeconds: a.media === MediaType.VIDEO ? 30 + adIndex : null
|
||||
}
|
||||
});
|
||||
for (const [type, value] of [
|
||||
["hook", a.text.split(".")[0]],
|
||||
["angle", `${a.niche} winning angle`],
|
||||
["persona", `${a.niche} buyers`],
|
||||
["emotion", a.rank < 15 ? "Urgency" : "Trust"]
|
||||
]) {
|
||||
await prisma.aITag.create({ data: { adId: ad.id, type, value, confidence: 0.91, model: "seed-demo", promptVersion: "v0" } });
|
||||
}
|
||||
adIndex++;
|
||||
}
|
||||
|
||||
const firstAd = await prisma.ad.findFirstOrThrow();
|
||||
const folder = await prisma.savedFolder.upsert({
|
||||
where: { userId_name: { userId: demo.id, name: "Kazanan Pet Reklamları" } },
|
||||
update: {},
|
||||
create: { userId: demo.id, name: "Kazanan Pet Reklamları", color: "#22c55e" }
|
||||
});
|
||||
await prisma.savedAd.upsert({
|
||||
where: { userId_adId: { userId: demo.id, adId: firstAd.id } },
|
||||
update: { folderId: folder.id },
|
||||
create: { userId: demo.id, adId: firstAd.id, folderId: folder.id, note: "Demo kayıt." }
|
||||
});
|
||||
const firstStore = await prisma.store.findFirstOrThrow();
|
||||
await prisma.trackedStore.upsert({
|
||||
where: { userId_storeId: { userId: admin.id, storeId: firstStore.id } },
|
||||
update: {},
|
||||
create: { userId: admin.id, storeId: firstStore.id, notes: "Demo takip." }
|
||||
});
|
||||
await prisma.usageCounter.upsert({
|
||||
where: { userId_metric_periodKey: { userId: demo.id, metric: "ads_search_daily", periodKey: new Date().toISOString().slice(0, 10) } },
|
||||
update: { used: 1, limit: 10 },
|
||||
create: { userId: demo.id, metric: "ads_search_daily", period: UsagePeriod.DAILY, periodKey: new Date().toISOString().slice(0, 10), used: 1, limit: 10 }
|
||||
});
|
||||
await prisma.ingestJob.create({ data: { source: "seed", type: "demo-data", status: "COMPLETED", startedAt: new Date(), finishedAt: new Date(), recordsImported: 18 } });
|
||||
|
||||
console.log("✅ Demo: demo@winninghunter.local / demo1234");
|
||||
console.log("✅ Admin: admin@winninghunter.local / admin1234");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => prisma.$disconnect());
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAdmin } from "@/lib/auth/current-user";
|
||||
|
||||
export async function POST() {
|
||||
await requireAdmin();
|
||||
return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { maskAdForPlan } from "@/lib/locked-response";
|
||||
import { planFromUser } from "@/lib/plans";
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const ad = await prisma.ad.findUnique({
|
||||
where: { id: params.id },
|
||||
include: { brandPage: true, creatives: true, aiTags: true, countryStats: true }
|
||||
});
|
||||
if (!ad) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
||||
const plan = planFromUser(user as any);
|
||||
return NextResponse.json({ data: maskAdForPlan(ad as any, plan?.code) });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function POST(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const ad = await prisma.ad.findUnique({ where: { id: params.id } });
|
||||
if (!ad) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
||||
await prisma.savedAd.upsert({
|
||||
where: { userId_adId: { userId: user.id, adId: params.id } },
|
||||
update: {},
|
||||
create: { userId: user.id, adId: params.id }
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
await prisma.savedAd.deleteMany({ where: { userId: user.id, adId: params.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { checkAndConsumeQuota } from "@/lib/quota";
|
||||
import { maskAdForPlan } from "@/lib/locked-response";
|
||||
import { planFromUser } from "@/lib/plans";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
|
||||
const plan = planFromUser(user as any);
|
||||
const quota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "ads_search_daily" });
|
||||
if (!quota.allowed) return NextResponse.json({ error: "QUOTA_EXCEEDED", usage: quota, upgradeRequired: true }, { status: 403 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q")?.trim();
|
||||
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
|
||||
const limit = Math.min(60, Math.max(1, Number(url.searchParams.get("limit") || 24)));
|
||||
const status = url.searchParams.get("status");
|
||||
const niche = url.searchParams.get("niche");
|
||||
const mediaType = url.searchParams.get("mediaType");
|
||||
const country = url.searchParams.get("country");
|
||||
const sort = url.searchParams.get("sort") || "lastSeen_desc";
|
||||
|
||||
const where: Prisma.AdWhereInput = {};
|
||||
if (status) where.status = status as any;
|
||||
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||
if (mediaType) where.mediaType = mediaType as any;
|
||||
if (country) where.countries = { has: country };
|
||||
if (q) {
|
||||
where.OR = [
|
||||
{ primaryText: { contains: q, mode: "insensitive" } },
|
||||
{ headline: { contains: q, mode: "insensitive" } },
|
||||
{ brandPage: { name: { contains: q, mode: "insensitive" } } }
|
||||
];
|
||||
}
|
||||
|
||||
const orderBy: Prisma.AdOrderByWithRelationInput =
|
||||
sort === "rank_asc" ? { rankPercentile: "asc" } : sort === "days_desc" ? { daysRunning: "desc" } : { lastSeenAt: "desc" };
|
||||
|
||||
const [total, rows, saved] = await Promise.all([
|
||||
prisma.ad.count({ where }),
|
||||
prisma.ad.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: { brandPage: true, creatives: { take: 1 } }
|
||||
}),
|
||||
prisma.savedAd.findMany({ where: { userId: user.id }, select: { adId: true } })
|
||||
]);
|
||||
|
||||
const savedIds = new Set(saved.map((s) => s.adId));
|
||||
const data = rows.map((ad) =>
|
||||
maskAdForPlan(
|
||||
{
|
||||
id: ad.id,
|
||||
source: ad.source,
|
||||
status: ad.status,
|
||||
mediaType: ad.mediaType,
|
||||
adScore: ad.adScore,
|
||||
primaryText: ad.primaryText,
|
||||
headline: ad.headline,
|
||||
ctaText: ad.ctaText,
|
||||
landingUrl: ad.landingUrl,
|
||||
productUrl: ad.productUrl,
|
||||
language: ad.language,
|
||||
countries: ad.countries,
|
||||
niche: ad.niche,
|
||||
daysRunning: ad.daysRunning,
|
||||
estimatedReachMin: ad.estimatedReachMin,
|
||||
estimatedReachMax: ad.estimatedReachMax,
|
||||
estimatedSpendMin: ad.estimatedSpendMin,
|
||||
estimatedSpendMax: ad.estimatedSpendMax,
|
||||
rankPercentile: ad.rankPercentile,
|
||||
brand: ad.brandPage && { id: ad.brandPage.id, name: ad.brandPage.name, logoUrl: ad.brandPage.logoUrl },
|
||||
thumbnailUrl: ad.creatives[0]?.thumbnailUrl || ad.creatives[0]?.url,
|
||||
isSaved: savedIds.has(ad.id)
|
||||
},
|
||||
plan?.code
|
||||
)
|
||||
);
|
||||
|
||||
return NextResponse.json({ data, pagination: { page, limit, total }, usage: quota });
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
|
||||
const schema = z.object({ email: z.string().email(), password: z.string().min(1) });
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = schema.parse(await req.json());
|
||||
const user = await prisma.user.findUnique({ where: { email: body.email } });
|
||||
if (!user?.passwordHash) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
|
||||
const ok = await bcrypt.compare(body.password, user.passwordHash);
|
||||
if (!ok) return NextResponse.json({ error: "INVALID_CREDENTIALS" }, { status: 401 });
|
||||
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||
await createSession(user.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { destroySession } from "@/lib/auth/session";
|
||||
|
||||
export async function POST() {
|
||||
await destroySession();
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ user: null });
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
plan: user.subscription?.plan.code
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
|
||||
const schema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
name: z.string().optional()
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const body = schema.parse(await req.json());
|
||||
const free = await prisma.plan.findUnique({ where: { code: "FREE" } });
|
||||
if (!free) return NextResponse.json({ error: "PLAN_NOT_SEEDED" }, { status: 500 });
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email: body.email } });
|
||||
if (exists) return NextResponse.json({ error: "EMAIL_EXISTS" }, { status: 409 });
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: body.email,
|
||||
name: body.name || body.email.split("@")[0],
|
||||
passwordHash: await bcrypt.hash(body.password, 10),
|
||||
subscription: { create: { planId: free.id, status: "ACTIVE" } }
|
||||
}
|
||||
});
|
||||
await createSession(user.id);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const plans = await prisma.plan.findMany({ where: { isActive: true }, orderBy: { sortOrder: "asc" } });
|
||||
return NextResponse.json({ data: plans });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const body = await req.json();
|
||||
const saved = await prisma.savedAd.updateMany({ where: { id: params.id, userId: user.id }, data: { folderId: body.folderId || null, note: body.note } });
|
||||
return NextResponse.json({ data: saved });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
await prisma.savedAd.deleteMany({ where: { id: params.id, userId: user.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const folderId = new URL(req.url).searchParams.get("folderId");
|
||||
const data = await prisma.savedAd.findMany({
|
||||
where: { userId: user.id, ...(folderId ? { folderId } : {}) },
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }
|
||||
});
|
||||
return NextResponse.json({ data });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const body = await req.json();
|
||||
const saved = await prisma.savedAd.upsert({
|
||||
where: { userId_adId: { userId: user.id, adId: body.adId } },
|
||||
update: { folderId: body.folderId || null, note: body.note },
|
||||
create: { userId: user.id, adId: body.adId, folderId: body.folderId || null, note: body.note }
|
||||
});
|
||||
return NextResponse.json({ data: saved });
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function PATCH(req: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const body = await req.json();
|
||||
await prisma.savedFolder.updateMany({ where: { id: params.id, userId: user.id }, data: { name: body.name, color: body.color } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
await prisma.savedFolder.deleteMany({ where: { id: params.id, userId: user.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
return NextResponse.json({ data: await prisma.savedFolder.findMany({ where: { userId: user.id }, orderBy: { sortOrder: "asc" } }) });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const body = await req.json();
|
||||
const folder = await prisma.savedFolder.create({ data: { userId: user.id, name: body.name, color: body.color } });
|
||||
return NextResponse.json({ data: folder });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const store = await prisma.store.findUnique({ where: { id: params.id }, include: { products: true, snapshots: { orderBy: { date: "asc" } }, pixels: true, apps: true, brandPages: { include: { ads: { include: { creatives: { take: 1 } }, take: 12 } } } } });
|
||||
if (!store) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
||||
return NextResponse.json({ data: store });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function GET(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const store = await prisma.store.findUnique({ where: { id: params.id } });
|
||||
if (!store) return NextResponse.json({ error: "NOT_FOUND" }, { status: 404 });
|
||||
const data = await prisma.store.findMany({ where: { id: { not: params.id }, niche: store.niche }, take: 20, orderBy: { monthlyVisits: "desc" } });
|
||||
return NextResponse.json({ data });
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const q = url.searchParams.get("q")?.trim();
|
||||
const niche = url.searchParams.get("niche");
|
||||
const country = url.searchParams.get("country");
|
||||
const sort = url.searchParams.get("sort") || "revenue_desc";
|
||||
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
|
||||
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit") || 25)));
|
||||
|
||||
const where: Prisma.StoreWhereInput = {};
|
||||
if (niche) where.niche = { contains: niche, mode: "insensitive" };
|
||||
if (country) where.country = country;
|
||||
if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }, { niche: { contains: q, mode: "insensitive" } }];
|
||||
|
||||
const orderBy: Prisma.StoreOrderByWithRelationInput = sort === "traffic_desc" ? { monthlyVisits: "desc" } : sort === "growth_desc" ? { monthlyVisitGrowth: "desc" } : { estRevenue30dMax: "desc" };
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.store.count({ where }),
|
||||
prisma.store.findMany({ where, orderBy, skip: (page - 1) * limit, take: limit, include: { products: { where: { isBestSeller: true }, take: 3 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } } })
|
||||
]);
|
||||
return NextResponse.json({ data: rows, pagination: { page, limit, total } });
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
|
||||
export async function DELETE(_: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const params = await context.params;
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
await prisma.trackedStore.deleteMany({ where: { id: params.id, userId: user.id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { getFeatureLimit, planFromUser } from "@/lib/plans";
|
||||
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const data = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 3 } } } }, orderBy: { createdAt: "desc" } });
|
||||
return NextResponse.json({ data });
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
const plan = planFromUser(user as any);
|
||||
const limit = getFeatureLimit(plan as any, "tracked_stores");
|
||||
const count = await prisma.trackedStore.count({ where: { userId: user.id } });
|
||||
if (limit !== null && count >= limit) return NextResponse.json({ error: "TRACKED_STORE_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 });
|
||||
const body = await req.json();
|
||||
const domain = String(body.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase();
|
||||
const store = await prisma.store.findUnique({ where: { domain } });
|
||||
if (!store) return NextResponse.json({ error: "STORE_NOT_FOUND" }, { status: 404 });
|
||||
const row = await prisma.trackedStore.upsert({ where: { userId_storeId: { userId: user.id, storeId: store.id } }, update: {}, create: { userId: user.id, storeId: store.id } });
|
||||
return NextResponse.json({ data: row });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { usageSummary } from "@/lib/quota";
|
||||
|
||||
export async function GET() {
|
||||
const user = await currentUser();
|
||||
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
|
||||
return NextResponse.json({ data: await usageSummary(user.id) });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function AccountPage() {
|
||||
const user = await requireUser();
|
||||
return <Card><h1 className="text-3xl font-black">My Account</h1><p className="mt-4 text-slate-600">{user.email} · Plan: {user.subscription?.plan.name}</p></Card>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { requireAdmin } from "@/lib/auth/current-user";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function AdminDataPage() {
|
||||
await requireAdmin();
|
||||
const [ads, stores, users, jobs] = await Promise.all([prisma.ad.count(), prisma.store.count(), prisma.user.count(), prisma.ingestJob.findMany({ orderBy: { createdAt: "desc" }, take: 10 })]);
|
||||
return <div><h1 className="mb-6 text-3xl font-black">Admin Data</h1><div className="grid gap-4 md:grid-cols-3"><Card><b>{ads}</b><br />Ads</Card><Card><b>{stores}</b><br />Stores</Card><Card><b>{users}</b><br />Users</Card></div><Card className="mt-5"><h2 className="mb-3 font-black">Ingest Jobs</h2>{jobs.map((j) => <div key={j.id} className="border-t py-2 text-sm">{j.source} · {j.type} · {j.status} · {j.recordsImported} records</div>)}</Card></div>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { maskAdForPlan } from "@/lib/locked-response";
|
||||
import { planFromUser } from "@/lib/plans";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function AdsPage({ searchParams }: { searchParams: Record<string, string | undefined> }) {
|
||||
const user = await requireUser();
|
||||
const plan = planFromUser(user as any);
|
||||
const q = searchParams.q?.trim();
|
||||
const niche = searchParams.niche;
|
||||
const mediaType = searchParams.mediaType;
|
||||
const where: Prisma.AdWhereInput = {};
|
||||
if (q) where.OR = [{ primaryText: { contains: q, mode: "insensitive" } }, { headline: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }];
|
||||
if (niche) where.niche = niche;
|
||||
if (mediaType) where.mediaType = mediaType as any;
|
||||
const ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 }, savedBy: { where: { userId: user.id } } }, orderBy: { rankPercentile: "asc" }, take: 48 });
|
||||
const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Search Meta Adlibrary</h1>
|
||||
<p className="mt-1 text-slate-500">Kazanan Meta reklamlarını keyword, niche ve medya tipine göre keşfet.</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-white px-4 py-3 text-sm shadow-sm">Plan: <b>{plan?.name}</b> · Free ise kartlar kilitli</div>
|
||||
</div>
|
||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_180px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder="dog collar, skincare, greens..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
<option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option>
|
||||
</select>
|
||||
<select name="mediaType" defaultValue={mediaType || ""} className="rounded-2xl border border-slate-200 px-4 py-3">
|
||||
<option value="">Tüm medya</option><option>VIDEO</option><option>IMAGE</option><option>CAROUSEL</option>
|
||||
</select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
|
||||
</form>
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => <span key={x} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-semibold text-violet-800">{x}</span>)}
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{masked.map((ad: any) => (
|
||||
<Card key={ad.id} className="relative overflow-hidden">
|
||||
{ad.isLocked && <div className="absolute inset-0 z-10 grid place-items-center bg-white/70 backdrop-blur-[2px]"><Link href="/pricing" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white">Start now — Unlock winners</Link></div>}
|
||||
<div className={ad.isLocked ? "locked-blur" : ""}>
|
||||
<img src={ad.creatives[0]?.thumbnailUrl || "https://placehold.co/640x480"} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" />
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="font-black">{ad.headline}</div>
|
||||
<div className="rounded-full bg-emerald-50 px-2 py-1 text-xs font-bold text-emerald-700">Top %{Math.round(ad.rankPercentile || 0)}</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-slate-500">{ad.brandPage?.name} · {ad.mediaType} · {ad.daysRunning} gün</div>
|
||||
<p className="mt-3 line-clamp-3 text-sm text-slate-600">{ad.primaryText}</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.countries.join(", ")}</b><br />Ülke</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>€{ad.estimatedSpendMin ?? "—"}</b><br />Min spend</div>
|
||||
<div className="rounded-xl bg-slate-50 p-2"><b>{ad.adScore}</b><br />Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
|
||||
const nav = [
|
||||
["Ads", "/dashboard/ads"],
|
||||
["Stores", "/dashboard/stores"],
|
||||
["Store Tracker", "/dashboard/store-tracker"],
|
||||
["Saved Ads", "/dashboard/saved-ads"],
|
||||
["TikTok Shop", "#"],
|
||||
["Magic AI", "#"],
|
||||
["Trends", "#"],
|
||||
["Brand Tracker", "#"]
|
||||
];
|
||||
|
||||
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireUser();
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4">
|
||||
<Link href="/dashboard/ads" className="text-xl font-black">WinningHunter<span className="text-violet-700">.AI</span></Link>
|
||||
<nav className="hidden gap-1 lg:flex">
|
||||
{nav.map(([label, href]) => (
|
||||
<Link key={label} href={href} className={`rounded-xl px-3 py-2 text-sm font-semibold ${href === "#" ? "cursor-not-allowed text-slate-400" : "text-slate-700 hover:bg-slate-100"}`}>
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/pricing" className="rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white">Upgrade</Link>
|
||||
<div className="text-right text-sm">
|
||||
<div className="font-bold">{user.name || user.email}</div>
|
||||
<div className="text-xs text-slate-500">{user.subscription?.plan.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-4 py-8">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function DashboardIndex() {
|
||||
redirect("/dashboard/ads");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function SavedAdsPage() {
|
||||
const user = await requireUser();
|
||||
const folders = await prisma.savedFolder.findMany({ where: { userId: user.id } });
|
||||
const saved = await prisma.savedAd.findMany({ where: { userId: user.id }, include: { folder: true, ad: { include: { brandPage: true, creatives: { take: 1 } } } }, orderBy: { createdAt: "desc" } });
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-3xl font-black">Saved Ads</h1>
|
||||
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||
<Card>
|
||||
<h2 className="mb-3 font-black">Folders</h2>
|
||||
<div className="space-y-2"><div className="rounded-xl bg-violet-50 p-3 font-bold text-violet-700">All Saved Ads ({saved.length})</div>{folders.map((f) => <div key={f.id} className="rounded-xl bg-slate-50 p-3 font-bold">{f.name}</div>)}</div>
|
||||
</Card>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{saved.map((s) => <Card key={s.id}><img src={s.ad.creatives[0]?.thumbnailUrl || ""} className="mb-4 h-40 w-full rounded-2xl object-cover" /><div className="font-black">{s.ad.headline}</div><div className="text-sm text-slate-500">{s.ad.brandPage?.name} · {s.folder?.name || "All"}</div><p className="mt-2 line-clamp-2 text-sm">{s.ad.primaryText}</p></Card>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function StoreTrackerPage() {
|
||||
const user = await requireUser();
|
||||
const tracked = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 2 } } } }, orderBy: { createdAt: "desc" } });
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">Store Tracker</h1>
|
||||
<p className="mt-1 text-slate-500">Plan limitine göre rakip Shopify mağazalarını watchlist’e ekle.</p>
|
||||
<Card className="mt-5">
|
||||
<form action="/api/tracked-stores" method="post" className="mb-5 flex gap-3">
|
||||
<input name="domain" placeholder="petpro-demo.com" className="flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white" type="button">API ile ekle</button>
|
||||
</form>
|
||||
<div className="space-y-3">
|
||||
{tracked.map((t) => <a key={t.id} href={`/dashboard/stores/${t.store.id}`} className="block rounded-2xl bg-slate-50 p-4"><div className="font-black">{t.store.name}</div><div className="text-sm text-slate-500">{t.store.domain} · {t.store.products.map((p) => p.title).join(", ")} · €{t.store.estRevenue30dMax?.toLocaleString()} est.</div></a>)}
|
||||
{!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">Free planda tracker kapalıdır. Admin demo hesabında örnek takip bulunur.</div>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function StoreDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = await params;
|
||||
await requireUser();
|
||||
const store = await prisma.store.findUnique({
|
||||
where: { id: resolvedParams.id },
|
||||
include: { products: true, snapshots: { orderBy: { date: "asc" } }, pixels: true, apps: true, brandPages: { include: { ads: { include: { creatives: { take: 1 } }, take: 12 } } } }
|
||||
});
|
||||
if (!store) notFound();
|
||||
const similar = await prisma.store.findMany({ where: { id: { not: store.id }, niche: store.niche }, take: 8, orderBy: { monthlyVisits: "desc" } });
|
||||
const ads = store.brandPages.flatMap((p) => p.ads);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img src={store.logoUrl || ""} className="h-16 w-16 rounded-2xl" />
|
||||
<div>
|
||||
<h1 className="text-3xl font-black">{store.name}</h1>
|
||||
<p className="text-slate-500">{store.domain} · {store.country} · {store.niche}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a href={store.shopUrl || "#"} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Visit Shop</a>
|
||||
</div>
|
||||
<div className="mb-5 grid gap-4 md:grid-cols-4">
|
||||
<Card><div className="text-sm text-slate-500">Monthly Visits</div><div className="mt-1 text-2xl font-black">{store.monthlyVisits?.toLocaleString()}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Est Revenue</div><div className="mt-1 text-2xl font-black">€{store.estRevenue30dMin?.toLocaleString()}–€{store.estRevenue30dMax?.toLocaleString()}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Products</div><div className="mt-1 text-2xl font-black">{store.productCount}</div></Card>
|
||||
<Card><div className="text-sm text-slate-500">Active Ads</div><div className="mt-1 text-2xl font-black">{ads.length}</div></Card>
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card>
|
||||
<h2 className="mb-4 text-xl font-black">Best-selling Products</h2>
|
||||
<div className="space-y-3">
|
||||
{store.products.map((p) => <div key={p.id} className="flex items-center justify-between rounded-2xl bg-slate-50 p-3"><div className="font-bold">{p.title}</div><div>{p.currency} {p.price}</div></div>)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<h2 className="mb-4 text-xl font-black">Pixels & Stack</h2>
|
||||
<div className="mb-4 flex flex-wrap gap-2">{store.pixels.map((p) => <span key={p.id} className="rounded-full bg-violet-50 px-3 py-1 text-sm font-bold text-violet-700">{p.type}</span>)}</div>
|
||||
<div className="flex flex-wrap gap-2">{store.apps.map((a) => <span key={a.id} className="rounded-full bg-slate-100 px-3 py-1 text-sm font-bold text-slate-700">{a.name}</span>)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="mt-5">
|
||||
<h2 className="mb-4 text-xl font-black">Top Meta Ads</h2>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{ads.map((ad) => <div key={ad.id} className="rounded-2xl border border-slate-100 p-3"><img src={ad.creatives[0]?.thumbnailUrl || ""} className="mb-3 h-32 w-full rounded-xl object-cover" /><div className="font-black">{ad.headline}</div><div className="text-sm text-slate-500">Top %{ad.rankPercentile} · {ad.daysRunning} gün</div></div>)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="mt-5">
|
||||
<h2 className="mb-4 text-xl font-black">Similar Stores</h2>
|
||||
<div className="grid gap-3 md:grid-cols-4">{similar.map((s) => <a key={s.id} href={`/dashboard/stores/${s.id}`} className="rounded-2xl bg-slate-50 p-4"><div className="font-black">{s.name}</div><div className="text-sm text-slate-500">{s.monthlyVisits?.toLocaleString()} visits</div></a>)}</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireUser } from "@/lib/auth/current-user";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function StoresPage({ searchParams }: { searchParams: Record<string, string | undefined> }) {
|
||||
await requireUser();
|
||||
const q = searchParams.q?.trim();
|
||||
const niche = searchParams.niche;
|
||||
const where: Prisma.StoreWhereInput = {};
|
||||
if (q) where.OR = [{ name: { contains: q, mode: "insensitive" } }, { domain: { contains: q, mode: "insensitive" } }];
|
||||
if (niche) where.niche = niche;
|
||||
const stores = await prisma.store.findMany({ where, include: { products: { where: { isBestSeller: true }, take: 2 }, brandPages: { include: { _count: { select: { ads: true } } }, take: 1 } }, orderBy: { estRevenue30dMax: "desc" }, take: 50 });
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-black">Explore Stores</h1>
|
||||
<p className="mt-1 text-slate-500">Shopify mağazalarını trafik, gelir, niche ve aktif reklam sayısıyla keşfet.</p>
|
||||
</div>
|
||||
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_220px_120px]">
|
||||
<input name="q" defaultValue={q} placeholder="petpro, beauty, supplements..." className="rounded-2xl border border-slate-200 px-4 py-3" />
|
||||
<select name="niche" defaultValue={niche || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm niche</option><option>Pets</option><option>Beauty</option><option>Supplements</option><option>Household</option></select>
|
||||
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Ara</button>
|
||||
</form>
|
||||
<Card className="overflow-hidden p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-left text-sm">
|
||||
<thead className="bg-slate-50 text-xs uppercase text-slate-500">
|
||||
<tr><th className="p-4">Shop Info</th><th>Best Sellers</th><th>Niche</th><th>Monthly Visits</th><th>Est. Revenue 30d</th><th>Meta Ads</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stores.map((s) => (
|
||||
<tr key={s.id} className="border-t border-slate-100">
|
||||
<td className="p-4"><div className="flex items-center gap-3"><img src={s.logoUrl || ""} className="h-10 w-10 rounded-xl" /><div><div className="font-black">{s.name}</div><div className="text-slate-500">{s.domain} · {s.country}</div></div></div></td>
|
||||
<td>{s.products.map((p) => p.title).join(", ")}</td>
|
||||
<td>{s.niche}</td>
|
||||
<td>{s.monthlyVisits?.toLocaleString()} <span className="text-emerald-600">+{s.monthlyVisitGrowth}%</span></td>
|
||||
<td>€{s.estRevenue30dMin?.toLocaleString()}–€{s.estRevenue30dMax?.toLocaleString()}</td>
|
||||
<td>{s.brandPages[0]?._count.ads || 0}</td>
|
||||
<td><Link href={`/dashboard/stores/${s.id}`} className="font-bold text-violet-700">Details</Link></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.locked-blur {
|
||||
filter: blur(5px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "WinningHunter MVP",
|
||||
description: "Dropshipping reklam ve mağaza istihbaratı MVP"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="tr">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("demo@winninghunter.local");
|
||||
const [password, setPassword] = useState("demo1234");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const res = await fetch("/api/auth/login", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password }) });
|
||||
if (!res.ok) {
|
||||
setError("Giriş başarısız. Demo: demo@winninghunter.local / demo1234");
|
||||
return;
|
||||
}
|
||||
location.href = "/dashboard/ads";
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-50 px-4">
|
||||
<form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft">
|
||||
<h1 className="text-3xl font-black">Giriş yap</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">Demo hesap hazır gelir.</p>
|
||||
<label className="mt-6 block text-sm font-bold">E-posta</label>
|
||||
<input className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<label className="mt-4 block text-sm font-bold">Şifre</label>
|
||||
<input type="password" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
<button className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white">Giriş</button>
|
||||
<a href="/register" className="mt-4 block text-center text-sm font-semibold text-violet-700">Hesap oluştur</a>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { LinkButton } from "@/components/ui/button";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<main className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,#ddd6fe,transparent_35%),#f8fafc]">
|
||||
<nav className="mx-auto flex max-w-7xl items-center justify-between px-6 py-6">
|
||||
<div className="text-xl font-black tracking-tight">WinningHunter<span className="text-violet-700">.AI</span></div>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton href="/login" className="bg-white text-slate-900 hover:bg-slate-100">Giriş</LinkButton>
|
||||
<LinkButton href="/pricing">Planlar</LinkButton>
|
||||
</div>
|
||||
</nav>
|
||||
<section className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||
<div>
|
||||
<div className="mb-5 inline-flex rounded-full border border-violet-200 bg-white/70 px-4 py-2 text-sm font-semibold text-violet-800">
|
||||
Meta Ads + Shopify Store Intelligence MVP
|
||||
</div>
|
||||
<h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight text-slate-950 md:text-7xl">
|
||||
Kazanan reklamları ve mağazaları dakikalar içinde keşfet.
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
Dropshipping ve e-ticaret için reklam kütüphanesi, mağaza keşfi, kaydetme, takip ve kota tabanlı üyelik altyapısı tek panelde.
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton>
|
||||
<LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">Demo panel</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="glass rounded-[2rem] p-4 shadow-2xl">
|
||||
<div className="rounded-[1.5rem] bg-slate-950 p-5 text-white">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-400">Live winners</span>
|
||||
<span className="rounded-full bg-emerald-400/20 px-3 py-1 text-xs text-emerald-300">Rising</span>
|
||||
</div>
|
||||
{["Smart Dog Collar", "LED Face Sculptor", "Greens Energy Blend"].map((item, i) => (
|
||||
<div key={item} className="mb-3 rounded-2xl bg-white/10 p-4">
|
||||
<div className="font-bold">{item}</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-white/10">
|
||||
<div className="h-2 rounded-full bg-violet-400" style={{ width: `${85 - i * 12}%` }} />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-400">Top %{4 + i * 6} · {27 + i * 9} days running</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { LinkButton } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default async function PricingPage() {
|
||||
const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []);
|
||||
const fallback = [
|
||||
{ code: "FREE", name: "Free", monthlyPriceEur: 0, features: { adsSearchDaily: 10, trackedStores: 0 } },
|
||||
{ code: "BASIC", name: "Basic", monthlyPriceEur: 42, features: { adsSearchDaily: 100, trackedStores: 25 } },
|
||||
{ code: "STANDARD", name: "Standard", monthlyPriceEur: 68, features: { adsSearchDaily: 500, trackedStores: 50 } },
|
||||
{ code: "PREMIUM", name: "Premium", monthlyPriceEur: 212, features: { adsSearchDaily: 2000, trackedStores: 500 } }
|
||||
];
|
||||
const rows = plans.length ? plans : fallback;
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-50 px-6 py-12">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-4xl font-black">Fiyatlandırma</h1>
|
||||
<p className="mt-3 text-slate-600">Kota tabanlı freemium model: arama, takip ve API kredileri planlara göre açılır.</p>
|
||||
</div>
|
||||
<div className="grid gap-5 md:grid-cols-4">
|
||||
{rows.map((plan: any) => (
|
||||
<Card key={plan.code} className={plan.code === "STANDARD" ? "ring-2 ring-violet-600" : ""}>
|
||||
<div className="text-sm font-bold text-violet-700">{plan.code}</div>
|
||||
<h2 className="mt-2 text-2xl font-black">{plan.name}</h2>
|
||||
<div className="mt-4 text-4xl font-black">€{plan.monthlyPriceEur}<span className="text-sm font-medium text-slate-500">/ay</span></div>
|
||||
<ul className="mt-6 space-y-2 text-sm text-slate-600">
|
||||
<li>Ads arama/gün: {String((plan.features as any).adsSearchDaily ?? "Sınırsız")}</li>
|
||||
<li>Store tracker: {String((plan.features as any).trackedStores ?? "Sınırsız")}</li>
|
||||
<li>Saved ads: {String((plan.features as any).savedAds ?? "Sınırsız")}</li>
|
||||
</ul>
|
||||
<LinkButton href="/register" className="mt-6 w-full">Başla</LinkButton>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
const res = await fetch("/api/auth/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email, password, name }) });
|
||||
if (!res.ok) {
|
||||
setError("Kayıt başarısız. E-posta kullanılıyor olabilir veya seed çalışmamış olabilir.");
|
||||
return;
|
||||
}
|
||||
location.href = "/dashboard/ads";
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="grid min-h-screen place-items-center bg-slate-50 px-4">
|
||||
<form onSubmit={submit} className="w-full max-w-md rounded-3xl bg-white p-8 shadow-soft">
|
||||
<h1 className="text-3xl font-black">Ücretsiz başla</h1>
|
||||
<label className="mt-6 block text-sm font-bold">Ad</label>
|
||||
<input className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<label className="mt-4 block text-sm font-bold">E-posta</label>
|
||||
<input className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<label className="mt-4 block text-sm font-bold">Şifre</label>
|
||||
<input type="password" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
{error && <div className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
<button className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-3 font-bold text-white">Hesap oluştur</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
|
||||
export function Button({ className = "", ...props }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-violet-800 disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkButton({ href, className = "", children }: { href: string; className?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link href={href} className={`inline-flex items-center justify-center rounded-xl bg-violet-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-violet-800 ${className}`}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function Card({ className = "", children }: { className?: string; children: React.ReactNode }) {
|
||||
return <div className={`rounded-3xl border border-slate-200 bg-white p-5 shadow-soft ${className}`}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
export function randomToken(bytes = 32) {
|
||||
return crypto.randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
|
||||
export function sha256(input: string) {
|
||||
return crypto.createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSessionUser } from "@/lib/auth/session";
|
||||
|
||||
export async function currentUser() {
|
||||
return getSessionUser();
|
||||
}
|
||||
|
||||
export async function requireUser() {
|
||||
const user = await currentUser();
|
||||
if (!user) redirect("/login");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const user = await requireUser();
|
||||
if (user.role !== "ADMIN") redirect("/dashboard/ads");
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { randomToken, sha256 } from "@/lib/auth/crypto";
|
||||
|
||||
export const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME || "wh_session";
|
||||
|
||||
export function sessionExpiry() {
|
||||
const days = Number(process.env.AUTH_SESSION_DAYS || 30);
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + days);
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
export async function createSession(userId: string) {
|
||||
const token = randomToken();
|
||||
const tokenHash = sha256(token);
|
||||
const expiresAt = sessionExpiry();
|
||||
|
||||
await prisma.authSession.create({
|
||||
data: { userId, tokenHash, expiresAt }
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(AUTH_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
expires: expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
export async function destroySession() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
if (token) {
|
||||
await prisma.authSession.deleteMany({ where: { tokenHash: sha256(token) } });
|
||||
}
|
||||
cookieStore.delete(AUTH_COOKIE_NAME);
|
||||
}
|
||||
|
||||
export async function getSessionUser() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(AUTH_COOKIE_NAME)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const session = await prisma.authSession.findUnique({
|
||||
where: { tokenHash: sha256(token) },
|
||||
include: {
|
||||
user: {
|
||||
include: {
|
||||
subscription: { include: { plan: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date()) return null;
|
||||
return session.user;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"]
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PlanCode } from "@prisma/client";
|
||||
|
||||
export function maskAdForPlan<T extends Record<string, any>>(ad: T, planCode?: PlanCode): T & { isLocked: boolean } {
|
||||
if (planCode && planCode !== "FREE") return { ...ad, isLocked: false };
|
||||
return {
|
||||
...ad,
|
||||
primaryText: ad.primaryText ? `${String(ad.primaryText).slice(0, 90)}...` : null,
|
||||
estimatedSpendMin: null,
|
||||
estimatedSpendMax: null,
|
||||
estimatedReachMin: null,
|
||||
estimatedReachMax: null,
|
||||
landingUrl: null,
|
||||
productUrl: null,
|
||||
isLocked: true
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Plan, PlanCode, Subscription } from "@prisma/client";
|
||||
|
||||
export type PlanWithFeatures = Plan & { features: Record<string, unknown> };
|
||||
|
||||
export const metricToFeature: Record<string, string> = {
|
||||
ads_search_daily: "adsSearchDaily",
|
||||
stores_search_daily: "storesSearchDaily",
|
||||
tiktok_search_daily: "tiktokSearchDaily",
|
||||
trends_search_daily: "trendsSearchDaily",
|
||||
api_credits_monthly: "apiCreditsMonthly",
|
||||
tracked_stores: "trackedStores",
|
||||
followed_brands: "followedBrands",
|
||||
saved_ads: "savedAds"
|
||||
};
|
||||
|
||||
export function planAllowsFullAds(planCode?: PlanCode) {
|
||||
return planCode && planCode !== "FREE";
|
||||
}
|
||||
|
||||
export function getFeatureLimit(plan: PlanWithFeatures | null | undefined, metric: string) {
|
||||
if (!plan) return 0;
|
||||
const featureKey = metricToFeature[metric] || metric;
|
||||
const value = plan.features?.[featureKey];
|
||||
if (value === null) return null;
|
||||
if (typeof value === "number") return value;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function planFromUser(user: { subscription?: (Subscription & { plan: Plan }) | null }) {
|
||||
return user.subscription?.plan as PlanWithFeatures | undefined;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { UsagePeriod } from "@prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getFeatureLimit } from "@/lib/plans";
|
||||
|
||||
function dayKey(date = new Date()) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function monthKey(date = new Date()) {
|
||||
return date.toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
function nextMidnight() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function nextMonthStart() {
|
||||
const d = new Date();
|
||||
d.setMonth(d.getMonth() + 1, 1);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function periodForMetric(metric: string) {
|
||||
if (metric.includes("monthly")) return UsagePeriod.MONTHLY;
|
||||
if (metric.includes("daily")) return UsagePeriod.DAILY;
|
||||
return UsagePeriod.LIFETIME;
|
||||
}
|
||||
|
||||
export function keyForPeriod(period: UsagePeriod) {
|
||||
if (period === UsagePeriod.MONTHLY) return monthKey();
|
||||
if (period === UsagePeriod.DAILY) return dayKey();
|
||||
return "lifetime";
|
||||
}
|
||||
|
||||
export function resetForPeriod(period: UsagePeriod) {
|
||||
if (period === UsagePeriod.MONTHLY) return nextMonthStart();
|
||||
if (period === UsagePeriod.DAILY) return nextMidnight();
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function usageSummary(userId: string) {
|
||||
const rows = await prisma.usageCounter.findMany({
|
||||
where: { userId },
|
||||
orderBy: { metric: "asc" }
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
metric: row.metric,
|
||||
used: row.used,
|
||||
limit: row.limit,
|
||||
remaining: row.limit === null ? null : Math.max(0, (row.limit ?? 0) - row.used),
|
||||
resetAt: row.resetAt
|
||||
}));
|
||||
}
|
||||
|
||||
export async function checkAndConsumeQuota(params: {
|
||||
userId: string;
|
||||
plan: { features: Record<string, unknown> } | null | undefined;
|
||||
metric: string;
|
||||
amount?: number;
|
||||
}) {
|
||||
const amount = params.amount ?? 1;
|
||||
const limit = getFeatureLimit(params.plan as any, params.metric);
|
||||
if (limit === null) {
|
||||
return { allowed: true, used: 0, limit: null, remaining: null, resetAt: null };
|
||||
}
|
||||
|
||||
const period = periodForMetric(params.metric);
|
||||
const periodKey = keyForPeriod(period);
|
||||
const resetAt = resetForPeriod(period);
|
||||
|
||||
const counter = await prisma.usageCounter.upsert({
|
||||
where: {
|
||||
userId_metric_periodKey: {
|
||||
userId: params.userId,
|
||||
metric: params.metric,
|
||||
periodKey
|
||||
}
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId: params.userId,
|
||||
metric: params.metric,
|
||||
period,
|
||||
periodKey,
|
||||
used: 0,
|
||||
limit,
|
||||
resetAt
|
||||
}
|
||||
});
|
||||
|
||||
if (counter.used + amount > limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
used: counter.used,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - counter.used),
|
||||
resetAt: counter.resetAt
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await prisma.usageCounter.update({
|
||||
where: { id: counter.id },
|
||||
data: { used: { increment: amount }, limit, resetAt }
|
||||
});
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
used: updated.used,
|
||||
limit,
|
||||
remaining: Math.max(0, limit - updated.used),
|
||||
resetAt: updated.resetAt
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
darkMode: "class",
|
||||
content: ["./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
DEFAULT: "#6d28d9",
|
||||
fg: "#ffffff"
|
||||
}
|
||||
},
|
||||
boxShadow: {
|
||||
soft: "0 20px 60px rgba(15, 23, 42, 0.08)"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user