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