Initial WinningHunter MVP

This commit is contained in:
2026-07-07 20:40:43 +03:00
commit 8dd43c57ca
57 changed files with 5295 additions and 0 deletions
+5
View File
@@ -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"
+9
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.env
*.log
.DS_Store
next-env.d.ts
/prisma/dev.db
+28
View File
@@ -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"]
+108
View File
@@ -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
+35
View File
@@ -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:
+10
View File
@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
typescript: { ignoreBuildErrors: false },
turbopack: {
root: process.cwd()
}
};
export default nextConfig;
+2736
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
+563
View File
@@ -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
View File
@@ -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" });
}
+18
View File
@@ -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) });
}
+25
View File
@@ -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 });
}
+87
View File
@@ -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 });
}
+18
View File
@@ -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 });
}
+7
View File
@@ -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 });
}
+16
View File
@@ -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
}
});
}
+31
View File
@@ -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 });
}
+7
View File
@@ -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 });
}
+20
View File
@@ -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 });
}
+27
View File
@@ -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 });
}
+20
View File
@@ -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 });
}
+17
View File
@@ -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 });
}
+12
View File
@@ -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 });
}
+13
View File
@@ -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 });
}
+30
View File
@@ -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 } });
}
+11
View File
@@ -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 });
}
+26
View File
@@ -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 });
}
+9
View File
@@ -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) });
}
+7
View File
@@ -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>;
}
+9
View File
@@ -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>;
}
+67
View File
@@ -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>
);
}
+41
View File
@@ -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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function DashboardIndex() {
redirect("/dashboard/ads");
}
+23
View File
@@ -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>
);
}
+24
View File
@@ -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ı watchliste 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>
);
}
+59
View File
@@ -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>
);
}
+50
View File
@@ -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>
);
}
+34
View File
@@ -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;
}
+15
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+49
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+37
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+3
View File
@@ -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>;
}
+9
View File
@@ -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");
}
+18
View File
@@ -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;
}
+60
View File
@@ -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;
}
+15
View File
@@ -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;
}
+16
View File
@@ -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
};
}
+31
View File
@@ -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;
}
+117
View File
@@ -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
};
}
+22
View File
@@ -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;
+41
View File
@@ -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"
]
}