feat: arastirma modulleri odeme ve operasyonlari tamamla

This commit is contained in:
2026-08-09 09:24:26 +03:00
parent 4b4e00c6ee
commit 2b75e97ad6
45 changed files with 1316 additions and 330 deletions
+22
View File
@@ -12,3 +12,25 @@ GOOGLE_CLIENT_SECRET=
# Apify Meta Ads ingestion (runtime secret; do not expose to the browser) # Apify Meta Ads ingestion (runtime secret; do not expose to the browser)
APIFY_TOKEN= APIFY_TOKEN=
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
APIFY_TIKTOK_ACTOR_ID=toolzerhub~tiktok-shop-products-scraper
# Magic AI (server-side only)
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.6-luna
# Stripe Billing (server-side only)
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_BASIC_MONTHLY=
STRIPE_PRICE_BASIC_YEARLY=
STRIPE_PRICE_STANDARD_MONTHLY=
STRIPE_PRICE_STANDARD_YEARLY=
STRIPE_PRICE_PREMIUM_MONTHLY=
STRIPE_PRICE_PREMIUM_YEARLY=
# Daily intelligence and optional email alerts
CRON_SECRET=
RESEND_API_KEY=
ALERT_FROM_EMAIL=
ALERT_WEBHOOK_URL=
ENABLE_DEMO_ACCOUNTS=false
+16
View File
@@ -12,6 +12,22 @@ GOOGLE_CLIENT_SECRET=
# Apify Meta Ads ingestion (runtime secret; do not expose to the browser) # Apify Meta Ads ingestion (runtime secret; do not expose to the browser)
APIFY_TOKEN= APIFY_TOKEN=
APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper
APIFY_TIKTOK_ACTOR_ID=toolzerhub~tiktok-shop-products-scraper
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.6-luna
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_BASIC_MONTHLY=
STRIPE_PRICE_BASIC_YEARLY=
STRIPE_PRICE_STANDARD_MONTHLY=
STRIPE_PRICE_STANDARD_YEARLY=
STRIPE_PRICE_PREMIUM_MONTHLY=
STRIPE_PRICE_PREMIUM_YEARLY=
CRON_SECRET=
RESEND_API_KEY=
ALERT_FROM_EMAIL=
ALERT_WEBHOOK_URL=
ENABLE_DEMO_ACCOUNTS=false
# For docker-compose # For docker-compose
POSTGRES_PASSWORD="change-this-long-random-password" POSTGRES_PASSWORD="change-this-long-random-password"
+12 -17
View File
@@ -24,15 +24,8 @@ npm run db:seed
npm run dev npm run dev
``` ```
Demo hesaplar: Üretimde hazır demo hesabı oluşturulmaz. Yalnızca yerel geliştirmede bilinçli olarak
`ENABLE_DEMO_ACCOUNTS=true` ayarlanırsa seed hesapları korunur.
```txt
Demo:
demo@winninghunter.local / demo1234
Admin:
admin@winninghunter.local / admin1234
```
## Production build ## Production build
@@ -53,9 +46,16 @@ curl http://localhost:3001/login
```bash ```bash
cp .env.production.example .env cp .env.production.example .env
docker compose up -d --build docker compose up -d --build
docker compose exec app npm run db:seed
``` ```
## Production operations
- Health endpoint: `GET /api/health`
- Daily intelligence: `POST /api/cron/daily-intelligence` with `Authorization: Bearer $CRON_SECRET`
- PostgreSQL backup: `npm run backup` (default retention: 14 days)
- External monitor check: `npm run health:check`; optional `ALERT_WEBHOOK_URL` receives failures
- Stripe webhook: `POST /api/billing/webhook`
## Coolify deploy ## Coolify deploy
1. Yeni proje oluştur. 1. Yeni proje oluştur.
@@ -69,15 +69,10 @@ NEXT_PUBLIC_APP_URL=https://winninghunter.your-domain.com
DATABASE_URL=postgresql://... DATABASE_URL=postgresql://...
AUTH_COOKIE_NAME=wh_session AUTH_COOKIE_NAME=wh_session
AUTH_SESSION_DAYS=30 AUTH_SESSION_DAYS=30
ADMIN_EMAILS=admin@winninghunter.local ADMIN_EMAILS=owner@example.com
``` ```
6. İlk deploy sonrası terminal: 6. Uygulama başlangıcında şema, plan seed'i ve `ADMIN_EMAILS` yetkileri otomatik uygulanır.
```bash
npx prisma db push
npm run db:seed
```
## Vercel deploy ## Vercel deploy
+25 -4
View File
@@ -24,16 +24,37 @@ services:
NODE_ENV: production NODE_ENV: production
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} 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 DATABASE_URL: postgresql://winninghunter:${POSTGRES_PASSWORD:-winninghunter_dev_password}@db:5432/winninghunter?schema=public
AUTH_COOKIE_NAME: wh_session AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-wh_session}
AUTH_SESSION_DAYS: 30 AUTH_SESSION_DAYS: ${AUTH_SESSION_DAYS:-30}
ADMIN_EMAILS: admin@winninghunter.local ADMIN_EMAILS: ${ADMIN_EMAILS:-}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
APIFY_TOKEN: ${APIFY_TOKEN:-} APIFY_TOKEN: ${APIFY_TOKEN:-}
APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_MODEL: ${OPENAI_MODEL:-gpt-5.6-luna}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
STRIPE_PRICE_BASIC_MONTHLY: ${STRIPE_PRICE_BASIC_MONTHLY:-}
STRIPE_PRICE_BASIC_YEARLY: ${STRIPE_PRICE_BASIC_YEARLY:-}
STRIPE_PRICE_STANDARD_MONTHLY: ${STRIPE_PRICE_STANDARD_MONTHLY:-}
STRIPE_PRICE_STANDARD_YEARLY: ${STRIPE_PRICE_STANDARD_YEARLY:-}
STRIPE_PRICE_PREMIUM_MONTHLY: ${STRIPE_PRICE_PREMIUM_MONTHLY:-}
STRIPE_PRICE_PREMIUM_YEARLY: ${STRIPE_PRICE_PREMIUM_YEARLY:-}
CRON_SECRET: ${CRON_SECRET:-}
RESEND_API_KEY: ${RESEND_API_KEY:-}
ALERT_FROM_EMAIL: ${ALERT_FROM_EMAIL:-}
ENABLE_DEMO_ACCOUNTS: ${ENABLE_DEMO_ACCOUNTS:-false}
APIFY_ACTOR_ID: ${APIFY_ACTOR_ID:-aiscraperdev~facebook-meta-ads-library-scraper} APIFY_ACTOR_ID: ${APIFY_ACTOR_ID:-aiscraperdev~facebook-meta-ads-library-scraper}
ports: ports:
- "${PORT:-3000}:3000" - "${PORT:-3000}:3000"
command: sh -c "npx prisma db push && npm run start" healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 45s
command: npm run start:deploy
volumes: volumes:
winninghunter_pg: winninghunter_pg:
+12
View File
@@ -1,9 +1,21 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
reactStrictMode: true, reactStrictMode: true,
poweredByHeader: false,
typescript: { ignoreBuildErrors: false }, typescript: { ignoreBuildErrors: false },
turbopack: { turbopack: {
root: process.cwd() root: process.cwd()
},
async headers() {
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(self)" },
{ key: "Content-Security-Policy", value: "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; connect-src 'self'; font-src 'self' data:; upgrade-insecure-requests" }
];
if (process.env.NODE_ENV === "production") securityHeaders.push({ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" });
return [{ source: "/(.*)", headers: securityHeaders }];
} }
}; };
+249 -191
View File
@@ -10,9 +10,10 @@
"dependencies": { "dependencies": {
"@prisma/client": "5.22.0", "@prisma/client": "5.22.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"next": "^16.3.0-canary.79", "next": "^16.3.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"stripe": "^22.4.0",
"zod": "3.23.8" "zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -21,7 +22,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"postcss": "8.4.47", "postcss": "^8.5.26",
"prisma": "5.22.0", "prisma": "5.22.0",
"tailwindcss": "3.4.13", "tailwindcss": "3.4.13",
"tsx": "4.23.4", "tsx": "4.23.4",
@@ -42,9 +43,9 @@
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.2", "version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
@@ -504,9 +505,9 @@
} }
}, },
"node_modules/@img/sharp-darwin-arm64": { "node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -516,19 +517,19 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4" "@img/sharp-libvips-darwin-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-darwin-x64": { "node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -538,19 +539,38 @@
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4" "@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-libvips-darwin-arm64": { "node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -564,9 +584,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-darwin-x64": { "node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -580,9 +600,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm": { "node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -596,9 +616,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-arm64": { "node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -612,9 +632,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-ppc64": { "node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -628,9 +648,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-riscv64": { "node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -644,9 +664,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-s390x": { "node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -660,9 +680,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linux-x64": { "node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -676,9 +696,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-arm64": { "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -692,9 +712,9 @@
} }
}, },
"node_modules/@img/sharp-libvips-linuxmusl-x64": { "node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -708,9 +728,9 @@
} }
}, },
"node_modules/@img/sharp-linux-arm": { "node_modules/@img/sharp-linux-arm": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -720,19 +740,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4" "@img/sharp-libvips-linux-arm": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-arm64": { "node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -742,19 +762,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4" "@img/sharp-libvips-linux-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-ppc64": { "node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -764,19 +784,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4" "@img/sharp-libvips-linux-ppc64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-riscv64": { "node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -786,19 +806,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4" "@img/sharp-libvips-linux-riscv64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-s390x": { "node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -808,19 +828,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4" "@img/sharp-libvips-linux-s390x": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linux-x64": { "node_modules/@img/sharp-linux-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -830,19 +850,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4" "@img/sharp-libvips-linux-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-arm64": { "node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -852,19 +872,19 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4" "@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-linuxmusl-x64": { "node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -874,38 +894,54 @@
"linux" "linux"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4" "@img/sharp-libvips-linuxmusl-x64": "1.3.2"
} }
}, },
"node_modules/@img/sharp-wasm32": { "node_modules/@img/sharp-wasm32": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/runtime": "^1.7.0" "@emnapi/runtime": "^1.11.1"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-arm64": { "node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -915,16 +951,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-ia32": { "node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -934,16 +970,16 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": "^20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@img/sharp-win32-x64": { "node_modules/@img/sharp-win32-x64": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -953,7 +989,7 @@
"win32" "win32"
], ],
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
@@ -999,15 +1035,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz",
"integrity": "sha512-F2U7ygW1qpAFXMLOp2vok9V5VWR2aKoQWX9sOPYpLk4NOlkkdeY5UAt1PDfm6nMPagL81AYEauwSU6N3iwUOng==", "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz",
"integrity": "sha512-A8I4zhxR5Dsvcbbnai1Mm/i5LxC/lm66tgI7I2tZbiLindg+lTKF7FwcAFyhnqZR89hNRGlxV0ZpCWPeQNZWEw==", "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1021,9 +1057,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz",
"integrity": "sha512-28a+1M3+MLUgGZqZC64vqjdonSTBNJXt60CNw4ZXCOkxPfzvp0wkvKjqL8ZYdLBBHBwTC7tUlmeEsnl7PWTXHg==", "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1037,9 +1073,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz",
"integrity": "sha512-MJ0oEhaFxnJ8LBK1zNelNqKQph+ZaWeqLpYOcFdl/aXM4PHHbv+5M5KEVKrbbdjwM2t+wdXmgQ2iozyUbdpaPw==", "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1053,9 +1089,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz",
"integrity": "sha512-CgjUs9aeJED98Zwl+bh+Ufo/M0ddPy+2BmupRt8SMaxJZa2wNNn4vognqPfHKr/pkiqr01X3hyuxNEk0CCyW5g==", "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1069,9 +1105,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz",
"integrity": "sha512-s2yPLT03R/gr/+FUMMecjgjsN1gUH0M6twOvIwhEMcL00LUB/q4RlVpHsVfAZ5DT3EV8GLI/XW2qhpJFaRQEyA==", "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1085,9 +1121,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz",
"integrity": "sha512-vhbTok2qmQ/cXl4XPoiI7wVAX0iYPdgGqPaP/YgQf3eyTBfU5Ej57mdsd2LFxD2ObTMzAhAC3VidnC6oFhFnPg==", "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1101,9 +1137,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz",
"integrity": "sha512-glPd/6XP6jiflTPSCTNGbMkZ7BbNy29nk9QOainug2nwgRHr2xCPxQQNyxwtedCNKjKqXMhLGX8HWMu8PjPpEg==", "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1117,9 +1153,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz",
"integrity": "sha512-m0BfBDihZgAHjgl7tkQVyd4jAyVGi1Y4E9taFSmcPbxqFm2gEwdRQ85xdOUBMe8FDQpwIs0xcv1obulpw3VDdA==", "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1258,7 +1294,7 @@
"version": "26.1.0", "version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.3.0" "undici-types": "~8.3.0"
@@ -1869,9 +1905,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -1887,16 +1923,16 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.3.0-canary.79", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.0-canary.79.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz",
"integrity": "sha512-rMWsAkNQ/+adD45tnz05D683vA/+mVBze7flZ2oV3qgSlPc5tviVlTnu18uz4eccO53yIuL8zd5sdvrMb4Us8w==", "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.3.0-canary.79", "@next/env": "16.3.0",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.5.10", "postcss": "8.5.23",
"styled-jsx": "5.1.6" "styled-jsx": "5.1.6"
}, },
"bin": { "bin": {
@@ -1906,15 +1942,15 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.0-canary.79", "@next/swc-darwin-arm64": "16.3.0",
"@next/swc-darwin-x64": "16.3.0-canary.79", "@next/swc-darwin-x64": "16.3.0",
"@next/swc-linux-arm64-gnu": "16.3.0-canary.79", "@next/swc-linux-arm64-gnu": "16.3.0",
"@next/swc-linux-arm64-musl": "16.3.0-canary.79", "@next/swc-linux-arm64-musl": "16.3.0",
"@next/swc-linux-x64-gnu": "16.3.0-canary.79", "@next/swc-linux-x64-gnu": "16.3.0",
"@next/swc-linux-x64-musl": "16.3.0-canary.79", "@next/swc-linux-x64-musl": "16.3.0",
"@next/swc-win32-arm64-msvc": "16.3.0-canary.79", "@next/swc-win32-arm64-msvc": "16.3.0",
"@next/swc-win32-x64-msvc": "16.3.0-canary.79", "@next/swc-win32-x64-msvc": "16.3.0",
"sharp": "^0.34.5" "sharp": "^0.35.3"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
@@ -1940,9 +1976,9 @@
} }
}, },
"node_modules/next/node_modules/postcss": { "node_modules/next/node_modules/postcss": {
"version": "8.5.10", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1959,7 +1995,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -2064,9 +2100,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.47", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2084,8 +2120,8 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.17",
"picocolors": "^1.1.0", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
"engines": { "engines": {
@@ -2345,48 +2381,53 @@
} }
}, },
"node_modules/sharp": { "node_modules/sharp": {
"version": "0.34.5", "version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
"semver": "^7.7.3" "semver": "^7.8.5"
}, },
"engines": { "engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0" "node": ">=20.9.0"
}, },
"funding": { "funding": {
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
}, },
"optionalDependencies": { "optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.34.5", "@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-linux-arm": "0.34.5", "@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-x64": "0.34.5", "@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-wasm32": "0.34.5", "@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-win32-arm64": "0.34.5", "@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-x64": "0.34.5" "@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
} }
}, },
"node_modules/source-map-js": { "node_modules/source-map-js": {
@@ -2398,6 +2439,23 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/stripe": {
"version": "22.4.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz",
"integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": { "node_modules/styled-jsx": {
"version": "5.1.6", "version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -2678,7 +2736,7 @@
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
+8 -4
View File
@@ -7,12 +7,15 @@
"build": "prisma generate && next build", "build": "prisma generate && next build",
"start": "next start", "start": "next start",
"start:deploy": "prisma db push && tsx prisma/seed-if-empty.ts && tsx prisma/bootstrap-admins.ts && next start", "start:deploy": "prisma db push && tsx prisma/seed-if-empty.ts && tsx prisma/bootstrap-admins.ts && next start",
"lint": "next lint", "lint": "tsc --noEmit",
"test": "tsx --test tests/core.test.ts",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio" "db:studio": "prisma studio",
"backup": "sh scripts/backup-postgres.sh",
"health:check": "sh scripts/monitor-health.sh"
}, },
"prisma": { "prisma": {
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
@@ -20,9 +23,10 @@
"dependencies": { "dependencies": {
"@prisma/client": "5.22.0", "@prisma/client": "5.22.0",
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"next": "^16.3.0-canary.79", "next": "^16.3.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"stripe": "^22.4.0",
"zod": "3.23.8" "zod": "3.23.8"
}, },
"devDependencies": { "devDependencies": {
@@ -31,7 +35,7 @@
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"postcss": "8.4.47", "postcss": "^8.5.26",
"prisma": "5.22.0", "prisma": "5.22.0",
"tailwindcss": "3.4.13", "tailwindcss": "3.4.13",
"tsx": "4.23.4", "tsx": "4.23.4",
+89
View File
@@ -130,6 +130,9 @@ model User {
payments Payment[] @relation("PaymentOwner") payments Payment[] @relation("PaymentOwner")
manualPayments Payment[] @relation("PaymentActor") manualPayments Payment[] @relation("PaymentActor")
adminAuditLogs AdminAuditLog[] @relation("AuditActor") adminAuditLogs AdminAuditLog[] @relation("AuditActor")
aiAnalyses AiAnalysis[]
brandAlerts BrandAlert[]
notifications Notification[]
@@index([email]) @@index([email])
} }
@@ -529,12 +532,98 @@ model TrackedBrand {
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
brandPage BrandPage @relation(fields: [brandPageId], references: [id], onDelete: Cascade) brandPage BrandPage @relation(fields: [brandPageId], references: [id], onDelete: Cascade)
alerts BrandAlert[]
@@unique([userId, brandPageId]) @@unique([userId, brandPageId])
@@index([userId]) @@index([userId])
@@index([brandPageId]) @@index([brandPageId])
} }
model TikTokProduct {
id String @id @default(cuid())
externalId String @unique
title String
description String?
productUrl String?
imageUrl String?
shopName String?
shopExternalId String?
region String?
currency String?
price Float?
originalPrice Float?
soldCount Int?
rating Float?
reviewCount Int?
category String?
raw Json?
firstSeenAt DateTime @default(now())
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([region])
@@index([soldCount])
@@index([lastSeenAt])
}
model AiAnalysis {
id String @id @default(cuid())
userId String
query String?
provider String
model String
summary String
insights Json
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt])
}
model TrendSnapshot {
id String @id @default(cuid())
date DateTime @unique
metrics Json
createdAt DateTime @default(now())
@@index([date])
}
model BrandAlert {
id String @id @default(cuid())
userId String
trackedBrandId String
adId String
type String @default("NEW_AD")
title String
details Json?
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
trackedBrand TrackedBrand @relation(fields: [trackedBrandId], references: [id], onDelete: Cascade)
@@unique([userId, trackedBrandId, adId, type])
@@index([userId, readAt, createdAt])
}
model Notification {
id String @id @default(cuid())
userId String
type String
title String
body String
link String?
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, readAt, createdAt])
}
model FilterPreset { model FilterPreset {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
+4
View File
@@ -30,6 +30,10 @@ async function main() {
console.log("🌱 Empty database detected. Running demo seed..."); console.log("🌱 Empty database detected. Running demo seed...");
await runSeed(); await runSeed();
if (process.env.ENABLE_DEMO_ACCOUNTS !== "true") {
await prisma.user.deleteMany({ where: { email: { in: ["demo@winninghunter.local", "admin@winninghunter.local"] } } });
console.log("🔒 Public demo accounts removed.");
}
} }
main() main()
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
backup_dir="${BACKUP_DIR:-./backups}"
mkdir -p "$backup_dir"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
target="$backup_dir/winninghunter-$timestamp.sql.gz"
docker compose exec -T db pg_dump -U winninghunter -d winninghunter | gzip -9 > "$target"
find "$backup_dir" -type f -name 'winninghunter-*.sql.gz' -mtime +"${BACKUP_RETENTION_DAYS:-14}" -delete
echo "Backup created: $target"
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
app_url="${NEXT_PUBLIC_APP_URL:?NEXT_PUBLIC_APP_URL is required}"
if curl --fail --silent --show-error --max-time 10 "${app_url%/}/api/health" >/dev/null; then
echo "WinningHunter healthcheck OK"
exit 0
fi
if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then
curl --fail --silent --show-error --max-time 10 -X POST -H 'content-type: application/json' --data '{"text":"WinningHunter healthcheck failed"}' "$ALERT_WEBHOOK_URL" >/dev/null || true
fi
echo "WinningHunter healthcheck FAILED" >&2
exit 1
@@ -1,8 +0,0 @@
import { NextResponse } from "next/server";
import { getApiAdmin } from "@/lib/admin-api";
export async function POST() {
const admin = await getApiAdmin();
if (!admin) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" });
}
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user";
import { importApifyAds, runApifyActor } from "@/lib/apify";
import { prisma } from "@/lib/db";
import { planFromUser } from "@/lib/plans";
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({
searchTerm: z.string().trim().min(2).max(100),
maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value))
});
function planResultLimit(planCode: string | undefined, isAdmin: boolean) {
if (isAdmin || planCode === "PREMIUM") return 100;
if (planCode === "STANDARD") return 50;
if (planCode === "BASIC") return 25;
return 0;
}
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "arama");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = planFromUser(user as never);
const isAdmin = user.role === "ADMIN";
const resultLimit = planResultLimit(plan?.code, isAdmin);
if (resultLimit === 0) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 });
if (parsed.data.maxResults > resultLimit) {
return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: resultLimit }, { status: 403 });
}
let dailyReserved = false;
let creditsReserved = false;
if (!isAdmin) {
const daily = await checkAndConsumeQuota({ userId: user.id, plan, metric: "ads_search_daily" });
if (!daily.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: daily }, { status: 403 });
dailyReserved = true;
const credits = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults });
if (!credits.allowed) {
await refundQuota({ userId: user.id, metric: "ads_search_daily" });
return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: credits }, { status: 403 });
}
creditsReserved = true;
}
const job = await prisma.ingestJob.create({
data: {
source: "apify",
type: "meta-ads-library",
status: "RUNNING",
startedAt: new Date(),
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults }
}
});
try {
const records = await runApifyActor({
searchTerms: [parsed.data.searchTerm],
country: "ALL",
adActiveStatus: "ACTIVE",
mediaType: "ALL",
maxResults: parsed.data.maxResults,
maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10),
scrapeAdDetails: true,
includeAboutPage: false
});
const result = await importApifyAds(records);
if (creditsReserved && records.length < parsed.data.maxResults) {
await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length });
}
await prisma.ingestJob.update({
where: { id: job.id },
data: {
status: "COMPLETED",
finishedAt: new Date(),
recordsImported: result.imported,
recordsFailed: result.failed,
metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length }
}
});
return NextResponse.json({ ok: true, ...result, received: records.length });
} catch (error) {
if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" });
if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_INGEST_FAILED";
await prisma.ingestJob.update({ where: { id: job.id }, data: { status: "FAILED", finishedAt: new Date(), errorMessage: code } });
return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 });
}
}
+4 -2
View File
@@ -5,10 +5,12 @@ import { prisma } from "@/lib/db";
import { createSession } from "@/lib/auth/session"; import { createSession } from "@/lib/auth/session";
import { hizSiniriAsimi } from "@/lib/rate-limit"; import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ email: z.string().email(), password: z.string().min(1) }); const schema = z.object({ email: z.string().email(), password: z.string().min(1).max(72) });
export async function POST(req: Request) { export async function POST(req: Request) {
const body = schema.parse(await req.json()); const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const body = parsed.data;
// Sayaç e-posta bazlı da tutulur: tek IP'den farklı hesaplara saldırı da yavaşlar. // Sayaç e-posta bazlı da tutulur: tek IP'den farklı hesaplara saldırı da yavaşlar.
const sinir = hizSiniriAsimi(req, "giris", body.email.trim().toLowerCase()); const sinir = hizSiniriAsimi(req, "giris", body.email.trim().toLowerCase());
if (sinir) return sinir; if (sinir) return sinir;
+4 -2
View File
@@ -9,7 +9,7 @@ import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ const schema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(6), password: z.string().min(10).max(72),
name: z.string().optional() name: z.string().optional()
}); });
@@ -18,7 +18,9 @@ export async function POST(req: Request) {
const sinir = hizSiniriAsimi(req, "kayit"); const sinir = hizSiniriAsimi(req, "kayit");
if (sinir) return sinir; if (sinir) return sinir;
const body = schema.parse(await req.json()); const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const body = parsed.data;
const email = body.email.trim().toLowerCase(); const email = body.email.trim().toLowerCase();
const isAdmin = isConfiguredAdminEmail(email); const isAdmin = isConfiguredAdminEmail(email);
const plan = await prisma.plan.findUnique({ where: { code: isAdmin ? "PREMIUM" : "FREE" } }); const plan = await prisma.plan.findUnique({ where: { code: isAdmin ? "PREMIUM" : "FREE" } });
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
import { stripeClient, stripePriceFor } from "@/lib/stripe";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ planCode: z.enum(["BASIC", "STANDARD", "PREMIUM"]), interval: z.enum(["monthly", "yearly"]).default("monthly") });
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "giris");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = await prisma.plan.findUnique({ where: { code: parsed.data.planCode } });
if (!plan || !plan.isActive) return NextResponse.json({ error: "PLAN_NOT_FOUND" }, { status: 404 });
try {
const stripe = stripeClient();
const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, "");
if (!appUrl || !appUrl.startsWith("https://")) throw new Error("APP_URL_INVALID");
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: stripePriceFor(plan.code, parsed.data.interval), quantity: 1 }],
customer: user.subscription?.providerCustomerId || undefined,
customer_email: user.subscription?.providerCustomerId ? undefined : user.email,
client_reference_id: user.id,
success_url: `${appUrl}/dashboard/account?checkout=success`,
cancel_url: `${appUrl}/pricing?checkout=canceled`,
allow_promotion_codes: true,
metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval },
subscription_data: { metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval } }
});
await prisma.payment.upsert({
where: { externalId: session.id },
create: { userId: user.id, amountCents: parsed.data.interval === "yearly" ? (plan.yearlyPriceEur || plan.monthlyPriceEur * 12) * 100 : plan.monthlyPriceEur * 100, currency: "EUR", status: "PENDING", provider: "STRIPE", externalId: session.id, description: `${plan.name} ${parsed.data.interval}` },
update: {}
});
return NextResponse.json({ url: session.url });
} catch (error) {
const code = error instanceof Error && /^(STRIPE_[A-Z0-9_]+|APP_URL_INVALID)$/.test(error.message) ? error.message : "CHECKOUT_FAILED";
return NextResponse.json({ error: code }, { status: code.includes("NOT_CONFIGURED") ? 503 : 502 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import Stripe from "stripe";
import { PaymentStatus, SubscriptionStatus } from "@prisma/client";
import { prisma } from "@/lib/db";
import { stripeClient } from "@/lib/stripe";
function status(value: Stripe.Subscription.Status): SubscriptionStatus {
if (value === "active") return SubscriptionStatus.ACTIVE;
if (value === "trialing") return SubscriptionStatus.TRIALING;
if (value === "past_due" || value === "unpaid" || value === "incomplete") return SubscriptionStatus.PAST_DUE;
if (value === "canceled" || value === "incomplete_expired") return SubscriptionStatus.CANCELED;
return SubscriptionStatus.EXPIRED;
}
async function syncSubscription(subscription: Stripe.Subscription) {
const userId = subscription.metadata.userId;
const planCode = subscription.metadata.planCode as "BASIC" | "STANDARD" | "PREMIUM" | undefined;
if (!userId || !planCode) return;
const plan = await prisma.plan.findUnique({ where: { code: planCode } });
if (!plan) return;
const item = subscription.items.data[0];
const start = item?.current_period_start ? new Date(item.current_period_start * 1000) : null;
const end = item?.current_period_end ? new Date(item.current_period_end * 1000) : null;
await prisma.subscription.upsert({
where: { userId },
create: { userId, planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id },
update: { planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id }
});
}
export async function POST(request: Request) {
const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim();
const signature = request.headers.get("stripe-signature");
if (!secret || !signature) return NextResponse.json({ error: "WEBHOOK_NOT_CONFIGURED" }, { status: 503 });
const raw = await request.text();
let event: Stripe.Event;
try { event = stripeClient().webhooks.constructEvent(raw, signature, secret, 300); }
catch { return NextResponse.json({ error: "INVALID_SIGNATURE" }, { status: 400 }); }
if (event.type === "checkout.session.completed") {
const session = event.data.object;
const userId = session.metadata?.userId || session.client_reference_id;
if (userId) {
await prisma.payment.upsert({
where: { externalId: session.id },
create: { userId, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), status: PaymentStatus.PAID, provider: "STRIPE", externalId: session.id, description: `Stripe ${session.metadata?.planCode || "subscription"}`, paidAt: new Date() },
update: { status: PaymentStatus.PAID, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), paidAt: new Date() }
});
if (typeof session.subscription === "string") await syncSubscription(await stripeClient().subscriptions.retrieve(session.subscription));
}
}
if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted" || event.type === "customer.subscription.created") await syncSubscription(event.data.object);
return NextResponse.json({ received: true });
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import { getApiAdmin } from "@/lib/admin-api";
import { captureTrendSnapshot, createBrandAlerts } from "@/lib/trend-snapshots";
export async function POST(request: Request) {
const configured = process.env.CRON_SECRET?.trim();
const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
const authorizedBySecret = Boolean(configured && bearer && bearer.length === configured.length && timingSafeEqual(Buffer.from(bearer), Buffer.from(configured)));
if (!authorizedBySecret && !(await getApiAdmin())) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 });
const snapshot = await captureTrendSnapshot();
const alertsCreated = await createBrandAlerts();
return NextResponse.json({ ok: true, snapshotDate: snapshot.date, alertsCreated });
}
+12
View File
@@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET() {
const started = Date.now();
try {
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({ status: "ok", database: "ok", latencyMs: Date.now() - started }, { headers: { "cache-control": "no-store" } });
} catch {
return NextResponse.json({ status: "degraded", database: "unavailable" }, { status: 503, headers: { "cache-control": "no-store" } });
}
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { Prisma } from "@prisma/client";
import { currentUser } from "@/lib/auth/current-user";
import { prisma } from "@/lib/db";
import { analyzeCreatives } from "@/lib/openai-analysis";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ query: z.string().trim().max(100).optional() });
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "arama");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const q = parsed.data.query;
const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {};
const ads = await prisma.ad.findMany({ where, include: { brandPage: true }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 20 });
if (!ads.length) return NextResponse.json({ error: "NO_ADS_TO_ANALYZE" }, { status: 404 });
try {
const result = await analyzeCreatives(q, ads.map((ad) => ({ headline: ad.headline, primaryText: ad.primaryText, mediaType: ad.mediaType, daysRunning: ad.daysRunning, status: ad.status, brand: ad.brandPage?.name || null })));
const saved = await prisma.aiAnalysis.create({ data: { userId: user.id, query: q, provider: "openai", model: result.model, summary: result.summary, insights: result.insights } });
return NextResponse.json({ data: saved });
} catch (error) {
const code = error instanceof Error && /^(OPENAI_[A-Z0-9_]+)$/.test(error.message) ? error.message : "AI_ANALYSIS_FAILED";
return NextResponse.json({ error: code }, { status: code === "OPENAI_NOT_CONFIGURED" ? 503 : 502 });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { currentUser } from "@/lib/auth/current-user";
import { importTikTokProducts, runTikTokShopActor } from "@/lib/apify-tiktok";
import { planFromUser } from "@/lib/plans";
import { checkAndConsumeQuota, refundQuota } from "@/lib/quota";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ query: z.string().trim().min(2).max(100), region: z.enum(["US", "GB", "DE", "FR", "TR"]).default("US"), maxResults: z.coerce.number().int().refine((v) => [10, 25, 50].includes(v)) });
export async function POST(request: Request) {
const limited = hizSiniriAsimi(request, "arama");
if (limited) return limited;
const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = planFromUser(user as never);
const isAdmin = user.role === "ADMIN";
if (!isAdmin && (!plan || plan.code === "FREE")) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 });
const max = isAdmin || plan?.code === "PREMIUM" ? 50 : plan?.code === "STANDARD" ? 25 : 10;
if (parsed.data.maxResults > max) return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: max }, { status: 403 });
let daily = false;
let credits = false;
if (!isAdmin) {
const dailyQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "tiktok_search_daily" });
if (!dailyQuota.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: dailyQuota }, { status: 403 });
daily = true;
const apiQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults });
if (!apiQuota.allowed) { await refundQuota({ userId: user.id, metric: "tiktok_search_daily" }); return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: apiQuota }, { status: 403 }); }
credits = true;
}
try {
const rows = await runTikTokShopActor(parsed.data);
const result = await importTikTokProducts(rows, parsed.data.region);
if (credits && rows.length < parsed.data.maxResults) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - rows.length });
return NextResponse.json({ ok: true, ...result, received: rows.length });
} catch (error) {
if (daily) await refundQuota({ userId: user.id, metric: "tiktok_search_daily" });
if (credits) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults });
const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_TIKTOK_FAILED";
return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 });
}
}
+10 -2
View File
@@ -2,6 +2,10 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { getFeatureLimit, planFromUser } from "@/lib/plans"; import { getFeatureLimit, planFromUser } from "@/lib/plans";
import { z } from "zod";
import { hizSiniriAsimi } from "@/lib/rate-limit";
const schema = z.object({ domain: z.string().trim().min(3).max(253) });
export async function GET() { export async function GET() {
const user = await currentUser(); const user = await currentUser();
@@ -11,14 +15,18 @@ export async function GET() {
} }
export async function POST(req: Request) { export async function POST(req: Request) {
const rateLimit = hizSiniriAsimi(req, "arama");
if (rateLimit) return rateLimit;
const user = await currentUser(); const user = await currentUser();
if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
const parsed = schema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 });
const plan = planFromUser(user as any); const plan = planFromUser(user as any);
const limit = getFeatureLimit(plan as any, "tracked_stores"); const limit = getFeatureLimit(plan as any, "tracked_stores");
const count = await prisma.trackedStore.count({ where: { userId: user.id } }); 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 }); if (limit !== null && count >= limit) return NextResponse.json({ error: "TRACKED_STORE_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 });
const body = await req.json(); let domain = parsed.data.domain.replace(/^https?:\/\//i, "").split("/")[0]!.toLowerCase().replace(/^www\./, "");
const domain = String(body.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase(); try { domain = new URL(`https://${domain}`).hostname; } catch { return NextResponse.json({ error: "INVALID_DOMAIN" }, { status: 400 }); }
const store = await prisma.store.findUnique({ where: { domain } }); const store = await prisma.store.findUnique({ where: { domain } });
if (!store) return NextResponse.json({ error: "STORE_NOT_FOUND" }, { status: 404 }); 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 } }); const row = await prisma.trackedStore.upsert({ where: { userId_storeId: { userId: user.id, storeId: store.id } }, update: {}, create: { userId: user.id, storeId: store.id } });
+2 -7
View File
@@ -21,6 +21,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
if (mediaType) where.mediaType = mediaType as any; 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 ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 }, savedBy: { where: { userId: user.id } } }, orderBy: { rankPercentile: "asc" }, take: 48 });
const isAdmin = user.role === "ADMIN"; const isAdmin = user.role === "ADMIN";
const apifyPlanLimit = isAdmin || plan?.code === "PREMIUM" ? 100 : plan?.code === "STANDARD" ? 50 : plan?.code === "BASIC" ? 25 : 0;
const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin)); const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin));
return ( return (
@@ -46,13 +47,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise<
{["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>)} {["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>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{q && masked.length === 0 && isAdmin && <ApifyEmptySearch query={q} />} {q && masked.length === 0 && <ApifyEmptySearch query={q} planLimit={apifyPlanLimit} />}
{q && masked.length === 0 && !isAdmin && (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white p-8 text-center md:col-span-2 xl:col-span-3">
<h2 className="text-xl font-black">{q} için sonuç bulunamadı</h2>
<p className="mt-2 text-sm text-slate-500">Yeni reklamların içe aktarılması için yöneticinizle iletişime geçin.</p>
</div>
)}
{masked.map((ad: any) => ( {masked.map((ad: any) => (
<Card key={ad.id} className="relative overflow-hidden"> <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>} {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>}
+4 -2
View File
@@ -15,7 +15,7 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
{ niche: { contains: query, mode: "insensitive" } } { niche: { contains: query, mode: "insensitive" } }
] ]
} : {}; } : {};
const [brands, tracked] = await Promise.all([ const [brands, tracked, alerts] = await Promise.all([
prisma.brandPage.findMany({ prisma.brandPage.findMany({
where, where,
include: { include: {
@@ -25,7 +25,8 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
orderBy: { ads: { _count: "desc" } }, orderBy: { ads: { _count: "desc" } },
take: 40 take: 40
}), }),
prisma.trackedBrand.findMany({ where: { userId: user.id } }) prisma.trackedBrand.findMany({ where: { userId: user.id } }),
prisma.brandAlert.findMany({ where: { userId: user.id }, include: { trackedBrand: { include: { brandPage: true } } }, orderBy: { createdAt: "desc" }, take: 20 })
]); ]);
const trackedByBrand = new Map(tracked.map((item) => [item.brandPageId, item.id])); const trackedByBrand = new Map(tracked.map((item) => [item.brandPageId, item.id]));
const limit = getFeatureLimit(planFromUser(user as any), "followed_brands"); const limit = getFeatureLimit(planFromUser(user as any), "followed_brands");
@@ -40,6 +41,7 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams:
<input name="q" defaultValue={query} placeholder="Marka, domain veya niche ara" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /> <input name="q" defaultValue={query} placeholder="Marka, domain veya niche ara" className="min-w-0 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">Ara</button> <button className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white">Ara</button>
</form> </form>
{alerts.length > 0 && <Card className="mb-5"><h2 className="text-lg font-black">Yeni reklam uyarıları</h2><div className="mt-3 space-y-2">{alerts.map((alert) => <div key={alert.id} className="flex items-center justify-between gap-3 rounded-2xl bg-violet-50 p-3 text-sm"><div><b>{alert.trackedBrand.brandPage.name}</b><p className="text-slate-600">{alert.title}</p></div><span className="shrink-0 text-xs text-slate-400">{alert.createdAt.toLocaleDateString("tr-TR")}</span></div>)}</div></Card>}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{brands.map((brand) => { {brands.map((brand) => {
const activeAds = brand.ads.filter((ad) => ad.status === "ACTIVE").length; const activeAds = brand.ads.filter((ad) => ad.status === "ACTIVE").length;
+3 -1
View File
@@ -1,5 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { DashboardMobileNav } from "@/components/dashboard-mobile-nav";
const nav = [ const nav = [
["Ads", "/dashboard/ads"], ["Ads", "/dashboard/ads"],
@@ -20,6 +21,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur"> <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"> <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> <Link href="/dashboard/ads" className="text-xl font-black">WinningHunter<span className="text-violet-700">.AI</span></Link>
<DashboardMobileNav isAdmin={user.role === "ADMIN"} />
<nav className="hidden gap-1 lg:flex"> <nav className="hidden gap-1 lg:flex">
{nav.map(([label, href]) => ( {nav.map(([label, href]) => (
<Link key={label} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100"> <Link key={label} href={href} className="rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100">
@@ -29,7 +31,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
{user.role === "ADMIN" && <Link href="/dashboard/admin" className="rounded-xl bg-violet-50 px-3 py-2 text-sm font-black text-violet-700 hover:bg-violet-100">Admin</Link>} {user.role === "ADMIN" && <Link href="/dashboard/admin" className="rounded-xl bg-violet-50 px-3 py-2 text-sm font-black text-violet-700 hover:bg-violet-100">Admin</Link>}
</nav> </nav>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user.role === "ADMIN" ? <Link href="/dashboard/admin" className="rounded-xl bg-slate-950 px-4 py-2 text-sm font-bold text-white lg:hidden">Admin</Link> : <Link href="/pricing" className="rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white">Upgrade</Link>} {user.role !== "ADMIN" && <Link href="/pricing" className="hidden rounded-xl bg-violet-700 px-4 py-2 text-sm font-bold text-white sm:block">Upgrade</Link>}
<div className="text-right text-sm"> <div className="text-right text-sm">
<div className="font-bold">{user.name || user.email}</div> <div className="font-bold">{user.name || user.email}</div>
<div className="text-xs text-slate-500">{user.subscription?.plan.name}</div> <div className="text-xs text-slate-500">{user.subscription?.plan.name}</div>
+8 -3
View File
@@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { AdCreativeMedia } from "@/components/ad-creative-media"; import { AdCreativeMedia } from "@/components/ad-creative-media";
import { MagicAiRunner } from "@/components/magic-ai-runner";
function sentence(value?: string | null) { function sentence(value?: string | null) {
return value?.split(/[.!?\n]/).map((item) => item.trim()).find(Boolean) || "Metin kancası bulunamadı"; return value?.split(/[.!?\n]/).map((item) => item.trim()).find(Boolean) || "Metin kancası bulunamadı";
@@ -17,15 +18,19 @@ function recommendation(ad: { daysRunning: number | null; mediaType: string; pri
} }
export default async function MagicAIPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { export default async function MagicAIPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
await requireUser(); const user = await requireUser();
const q = (await searchParams).q?.trim(); const q = (await searchParams).q?.trim();
const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {}; const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {};
const ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 } }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 18 }); const [ads, analyses] = await Promise.all([
prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 } }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 18 }),
prisma.aiAnalysis.findMany({ where: { userId: user.id }, orderBy: { createdAt: "desc" }, take: 5 })
]);
return ( return (
<div> <div>
<div className="mb-6"><h1 className="text-3xl font-black">Magic AI</h1><p className="mt-1 text-slate-500">Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.</p></div> <div className="mb-6"><h1 className="text-3xl font-black">Magic AI</h1><p className="mt-1 text-slate-500">Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.</p></div>
<form className="mb-5 flex gap-3 rounded-3xl bg-white p-4 shadow-soft"><input name="q" defaultValue={q} placeholder="Ürün, marka veya reklam metni ara" className="min-w-0 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">Analiz et</button></form> <MagicAiRunner />
{analyses.length > 0 && <div className="mb-5 grid gap-4 lg:grid-cols-2">{analyses.map((analysis) => <Card key={analysis.id}><div className="flex justify-between gap-3"><h2 className="font-black">{analysis.query || "Genel kreatif analizi"}</h2><span className="text-xs text-slate-400">{analysis.model}</span></div><p className="mt-2 text-sm text-slate-600">{analysis.summary}</p><div className="mt-3 space-y-2">{(analysis.insights as Array<{title:string;action:string;confidence:number}>).map((item, index) => <div key={index} className="rounded-2xl bg-violet-50 p-3 text-sm"><b>{item.title}</b><p className="mt-1 text-slate-600">{item.action}</p></div>)}</div></Card>)}</div>}
<div className="mb-5 rounded-3xl bg-gradient-to-r from-violet-700 to-fuchsia-600 p-6 text-white"><div className="text-sm font-bold uppercase tracking-widest text-violet-100">Creative Intelligence</div><h2 className="mt-2 text-2xl font-black">{ads.length} reklamdan aksiyon planı</h2><p className="mt-1 text-violet-100">Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.</p></div> <div className="mb-5 rounded-3xl bg-gradient-to-r from-violet-700 to-fuchsia-600 p-6 text-white"><div className="text-sm font-bold uppercase tracking-widest text-violet-100">Creative Intelligence</div><h2 className="mt-2 text-2xl font-black">{ads.length} reklamdan aksiyon planı</h2><p className="mt-1 text-violet-100">Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.</p></div>
<div className="grid gap-5 lg:grid-cols-2">{ads.map((ad) => { const insight = recommendation(ad); return <Card key={ad.id}> <div className="grid gap-5 lg:grid-cols-2">{ads.map((ad) => { const insight = recommendation(ad); return <Card key={ad.id}>
<div className="grid gap-4 sm:grid-cols-[180px_1fr]"><AdCreativeMedia creative={ad.creatives[0]} className="h-44 w-full rounded-2xl object-cover" /><div><div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{ad.headline || "Başlıksız reklam"}</h2><p className="text-sm text-slate-500">{ad.brandPage?.name || "Bilinmeyen marka"} · {ad.mediaType} · {ad.daysRunning || "—"} gün</p></div><span className={`rounded-full px-3 py-1 text-xs font-black ${insight.color}`}>{insight.label}</span></div><div className="mt-4 rounded-2xl bg-slate-50 p-3"><div className="text-xs font-bold uppercase tracking-wide text-slate-400">Kanca</div><p className="mt-1 line-clamp-2 text-sm font-semibold">{sentence(ad.primaryText)}</p></div><p className="mt-3 text-sm text-slate-600">{insight.text}</p></div></div> <div className="grid gap-4 sm:grid-cols-[180px_1fr]"><AdCreativeMedia creative={ad.creatives[0]} className="h-44 w-full rounded-2xl object-cover" /><div><div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{ad.headline || "Başlıksız reklam"}</h2><p className="text-sm text-slate-500">{ad.brandPage?.name || "Bilinmeyen marka"} · {ad.mediaType} · {ad.daysRunning || "—"} gün</p></div><span className={`rounded-full px-3 py-1 text-xs font-black ${insight.color}`}>{insight.label}</span></div><div className="mt-4 rounded-2xl bg-slate-50 p-3"><div className="text-xs font-bold uppercase tracking-wide text-slate-400">Kanca</div><p className="mt-1 line-clamp-2 text-sm font-semibold">{sentence(ad.primaryText)}</p></div><p className="mt-3 text-sm text-slate-600">{insight.text}</p></div></div>
+4 -8
View File
@@ -1,23 +1,19 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { StoreTrackerManager } from "@/components/store-tracker-manager";
import { getFeatureLimit, planFromUser } from "@/lib/plans";
export default async function StoreTrackerPage() { export default async function StoreTrackerPage() {
const user = await requireUser(); 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" } }); const tracked = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 2 } } } }, orderBy: { createdAt: "desc" } });
const limit = getFeatureLimit(planFromUser(user as any), "tracked_stores");
return ( return (
<div> <div>
<h1 className="text-3xl font-black">Store Tracker</h1> <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> <p className="mt-1 text-slate-500">Plan limitine göre rakip Shopify mağazalarını watchliste ekle.</p>
<Card className="mt-5"> <Card className="mt-5">
<form action="/api/tracked-stores" method="post" className="mb-5 flex gap-3"> <StoreTrackerManager tracked={tracked} limit={limit} />
<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> </Card>
</div> </div>
); );
+27 -23
View File
@@ -1,42 +1,46 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireUser } from "@/lib/auth/current-user"; import { requireUser } from "@/lib/auth/current-user";
import { planFromUser } from "@/lib/plans";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { TikTokShopImport } from "@/components/tiktok-shop-import";
function opportunityScore(product: { isBestSeller: boolean; price: number | null; store: { monthlyVisitGrowth: number | null; monthlyVisits: number | null } }) { function opportunityScore(product: { soldCount: number | null; rating: number | null; reviewCount: number | null; price: number | null }) {
const growth = Math.max(0, product.store.monthlyVisitGrowth || 0); const sales = Math.min(45, Math.log10(Math.max(1, product.soldCount || 1)) * 10);
const traffic = Math.min(25, Math.log10(Math.max(1, product.store.monthlyVisits || 1)) * 4); const reviews = Math.min(15, Math.log10(Math.max(1, product.reviewCount || 1)) * 4);
const rating = Math.min(20, Math.max(0, (product.rating || 0) * 4));
const priceFit = product.price && product.price >= 15 && product.price <= 80 ? 20 : 8; const priceFit = product.price && product.price >= 15 && product.price <= 80 ? 20 : 8;
return Math.min(99, Math.round(20 + growth + traffic + priceFit + (product.isBestSeller ? 15 : 0))); return Math.min(99, Math.round(sales + reviews + rating + priceFit));
} }
export default async function TikTokShopPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) { export default async function TikTokShopPage({ searchParams }: { searchParams: Promise<Record<string, string | undefined>> }) {
await requireUser(); const user = await requireUser();
const plan = planFromUser(user as never);
const planLimit = user.role === "ADMIN" || plan?.code === "PREMIUM" ? 50 : plan?.code === "STANDARD" ? 25 : plan?.code === "BASIC" ? 10 : 0;
const resolved = await searchParams; const resolved = await searchParams;
const q = resolved.q?.trim(); const q = resolved.q?.trim();
const country = resolved.country?.trim(); const region = resolved.region?.trim();
const where: Prisma.StoreProductWhereInput = { const where: Prisma.TikTokProductWhereInput = {
store: { pixels: { some: { type: { contains: "TikTok", mode: "insensitive" } } }, ...(country ? { country } : {}) }, ...(region ? { region } : {}),
...(q ? { OR: [{ title: { contains: q, mode: "insensitive" } }, { store: { name: { contains: q, mode: "insensitive" } } }] } : {}) ...(q ? { OR: [{ title: { contains: q, mode: "insensitive" } }, { shopName: { contains: q, mode: "insensitive" } }, { category: { contains: q, mode: "insensitive" } }] } : {})
}; };
const products = await prisma.storeProduct.findMany({ where, include: { store: true }, take: 60 }); const products = await prisma.tikTokProduct.findMany({ where, orderBy: [{ soldCount: "desc" }, { lastSeenAt: "desc" }], take: 60 });
const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score); const ranked = products.map((product) => ({ ...product, score: opportunityScore(product) })).sort((a, b) => b.score - a.score);
const countries = [...new Set(products.map((product) => product.store.country).filter(Boolean))] as string[]; const regions = [...new Set(products.map((product) => product.region).filter(Boolean))] as string[];
return <div>
return ( <div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">Apify üzerinden canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.</p></div>
<div> <TikTokShopImport planLimit={planLimit} />
<div className="mb-6"><h1 className="text-3xl font-black">TikTok Shop</h1><p className="mt-1 text-slate-500">TikTok Pixel bulunan mağazalardaki ürünleri büyüme, trafik, fiyat ve bestseller sinyalleriyle sırala.</p></div>
<form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]"> <form className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_180px_120px]">
<input name="q" defaultValue={q} placeholder="Ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" /> <input name="q" defaultValue={q} placeholder="Kayıtlı ürün veya mağaza ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
<select name="country" defaultValue={country || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm ülkeler</option>{countries.map((item) => <option key={item}>{item}</option>)}</select> <select name="region" defaultValue={region || ""} className="rounded-2xl border border-slate-200 px-4 py-3"><option value="">Tüm bölgeler</option>{regions.map((item) => <option key={item}>{item}</option>)}</select>
<button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button> <button className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white">Filtrele</button>
</form> </form>
<div className="mb-5 grid gap-4 sm:grid-cols-3"><Card><div className="text-sm text-slate-500">Ürün sinyali</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card><Card><div className="text-sm text-slate-500">TikTok Pixel mağazası</div><div className="mt-1 text-3xl font-black">{new Set(ranked.map((item) => item.storeId)).size}</div></Card><Card><div className="text-sm text-slate-500">Ortalama fırsat skoru</div><div className="mt-1 text-3xl font-black">{ranked.length ? Math.round(ranked.reduce((sum, item) => sum + item.score, 0) / ranked.length) : 0}</div></Card></div> <div className="mb-5 grid gap-4 sm:grid-cols-3"><Card><div className="text-sm text-slate-500">Canlı ürün</div><div className="mt-1 text-3xl font-black">{ranked.length}</div></Card><Card><div className="text-sm text-slate-500">Mağaza</div><div className="mt-1 text-3xl font-black">{new Set(ranked.map((item) => item.shopExternalId || item.shopName)).size}</div></Card><Card><div className="text-sm text-slate-500">Toplam satış sinyali</div><div className="mt-1 text-3xl font-black">{ranked.reduce((sum, item) => sum + (item.soldCount || 0), 0).toLocaleString("tr-TR")}</div></Card></div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{ranked.map((product) => <Card key={product.id}> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{ranked.map((product) => <Card key={product.id}>
{product.imageUrl ? <img src={product.imageUrl} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" /> : <div className="mb-4 grid h-44 place-items-center rounded-2xl bg-slate-100 text-slate-400">Ürün görseli yok</div>} {product.imageUrl ? <img src={product.imageUrl} alt="" className="mb-4 h-44 w-full rounded-2xl object-cover" /> : <div className="mb-4 grid h-44 place-items-center rounded-2xl bg-slate-100 text-slate-400">Ürün görseli yok</div>}
<div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{product.title}</h2><p className="text-sm text-slate-500">{product.store.name} · {product.store.country}</p></div><span className="rounded-full bg-fuchsia-50 px-3 py-1 text-sm font-black text-fuchsia-700">{product.score}</span></div> <div className="flex items-start justify-between gap-3"><div><h2 className="font-black">{product.title}</h2><p className="text-sm text-slate-500">{product.shopName || "Mağaza bilinmiyor"} · {product.region}</p></div><span className="rounded-full bg-fuchsia-50 px-3 py-1 text-sm font-black text-fuchsia-700">{product.score}</span></div>
<div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div className="rounded-xl bg-slate-50 p-2"><b>{product.price ? `${product.price} ${product.currency || product.store.currency || ""}` : "—"}</b><br />Fiyat</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.store.monthlyVisitGrowth || 0}%</b><br />Büyüme</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.isBestSeller ? "Evet" : "Hayır"}</b><br />Bestseller</div></div> <div className="mt-4 grid grid-cols-3 gap-2 text-center text-xs"><div className="rounded-xl bg-slate-50 p-2"><b>{product.price ? `${product.price} ${product.currency || ""}` : "—"}</b><br />Fiyat</div><div className="rounded-xl bg-slate-50 p-2"><b>{(product.soldCount || 0).toLocaleString("tr-TR")}</b><br />Satış</div><div className="rounded-xl bg-slate-50 p-2"><b>{product.rating || "—"}</b><br />Puan</div></div>
</Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">TikTok Pixel sinyalli ürün bulunamadı.</Card>}</div> {product.productUrl && <a href={product.productUrl} target="_blank" rel="noreferrer" className="mt-4 inline-block text-sm font-bold text-violet-700">TikTokta </a>}
</div> </Card>)}{!ranked.length && <Card className="text-center text-slate-500 md:col-span-2 xl:col-span-3">Yukarıdan bir ürün aratıp canlı veriyi getirin.</Card>}</div>
); </div>;
} }
+4 -2
View File
@@ -14,9 +14,10 @@ function Bars({ rows, total }: { rows: [string, number][]; total: number }) {
export default async function TrendsPage() { export default async function TrendsPage() {
await requireUser(); await requireUser();
const [ads, stores] = await Promise.all([ const [ads, stores, history] = await Promise.all([
prisma.ad.findMany({ select: { niche: true, mediaType: true, countries: true, status: true, daysRunning: true, firstSeenAt: true }, orderBy: { updatedAt: "desc" }, take: 500 }), prisma.ad.findMany({ select: { niche: true, mediaType: true, countries: true, status: true, daysRunning: true, firstSeenAt: true }, orderBy: { updatedAt: "desc" }, take: 500 }),
prisma.store.findMany({ select: { niche: true, monthlyVisitGrowth: true, name: true, country: true }, orderBy: { monthlyVisitGrowth: "desc" }, take: 20 }) prisma.store.findMany({ select: { niche: true, monthlyVisitGrowth: true, name: true, country: true }, orderBy: { monthlyVisitGrowth: "desc" }, take: 20 }),
prisma.trendSnapshot.findMany({ orderBy: { date: "desc" }, take: 14 })
]); ]);
const active = ads.filter((ad) => ad.status === "ACTIVE"); const active = ads.filter((ad) => ad.status === "ACTIVE");
const longRunners = ads.filter((ad) => (ad.daysRunning || 0) >= 30); const longRunners = ads.filter((ad) => (ad.daysRunning || 0) >= 30);
@@ -31,6 +32,7 @@ export default async function TrendsPage() {
<div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"><Card><div className="text-sm text-slate-500">İzlenen reklam</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card><Card><div className="text-sm text-slate-500">Aktif reklam</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card><Card><div className="text-sm text-slate-500">30+ gün yaşayan</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card><Card><div className="text-sm text-slate-500">Son 14 gün</div><div className="mt-1 text-3xl font-black text-violet-700">{newAds.length}</div></Card></div> <div className="mb-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"><Card><div className="text-sm text-slate-500">İzlenen reklam</div><div className="mt-1 text-3xl font-black">{ads.length}</div></Card><Card><div className="text-sm text-slate-500">Aktif reklam</div><div className="mt-1 text-3xl font-black text-emerald-600">{active.length}</div></Card><Card><div className="text-sm text-slate-500">30+ gün yaşayan</div><div className="mt-1 text-3xl font-black">{longRunners.length}</div></Card><Card><div className="text-sm text-slate-500">Son 14 gün</div><div className="mt-1 text-3xl font-black text-violet-700">{newAds.length}</div></Card></div>
<div className="grid gap-5 lg:grid-cols-3"><Card><h2 className="mb-4 text-lg font-black">Niche dağılımı</h2><Bars rows={niches} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Medya formatı</h2><Bars rows={media} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Ülke sinyali</h2><Bars rows={countries} total={ads.reduce((sum, ad) => sum + ad.countries.length, 0)} /></Card></div> <div className="grid gap-5 lg:grid-cols-3"><Card><h2 className="mb-4 text-lg font-black">Niche dağılımı</h2><Bars rows={niches} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Medya formatı</h2><Bars rows={media} total={ads.length} /></Card><Card><h2 className="mb-4 text-lg font-black">Ülke sinyali</h2><Bars rows={countries} total={ads.reduce((sum, ad) => sum + ad.countries.length, 0)} /></Card></div>
<Card className="mt-5"><div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-black">Hızlı büyüyen mağazalar</h2><p className="text-sm text-slate-500">Aylık ziyaret büyümesine göre</p></div></div><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">{stores.slice(0, 8).map((store) => <div key={store.name} className="rounded-2xl border border-slate-100 p-4"><div className="font-black">{store.name}</div><div className="text-sm text-slate-500">{store.niche || "Niche yok"} · {store.country || "—"}</div><div className="mt-3 text-2xl font-black text-emerald-600">+{store.monthlyVisitGrowth || 0}%</div></div>)}</div></Card> <Card className="mt-5"><div className="mb-4 flex items-center justify-between"><div><h2 className="text-lg font-black">Hızlı büyüyen mağazalar</h2><p className="text-sm text-slate-500">Aylık ziyaret büyümesine göre</p></div></div><div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">{stores.slice(0, 8).map((store) => <div key={store.name} className="rounded-2xl border border-slate-100 p-4"><div className="font-black">{store.name}</div><div className="text-sm text-slate-500">{store.niche || "Niche yok"} · {store.country || "—"}</div><div className="mt-3 text-2xl font-black text-emerald-600">+{store.monthlyVisitGrowth || 0}%</div></div>)}</div></Card>
<Card className="mt-5"><h2 className="text-lg font-black">14 günlük geçmiş</h2><p className="mb-4 text-sm text-slate-500">Günlük otomasyonla kaydedilen reklam ve TikTok Shop sinyalleri</p><div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">{history.map((snapshot) => { const metrics = snapshot.metrics as any; return <div key={snapshot.id} className="rounded-2xl bg-slate-50 p-3"><b>{snapshot.date.toLocaleDateString("tr-TR")}</b><p className="mt-1 text-sm text-slate-600">{metrics.ads?.active || 0} aktif reklam</p><p className="text-sm text-slate-600">{metrics.tiktok?.total || 0} TikTok ürün</p></div>; })}{!history.length && <p className="text-sm text-slate-500">İlk günlük snapshot henüz oluşmadı.</p>}</div></Card>
</div> </div>
); );
} }
+2 -27
View File
@@ -27,24 +27,7 @@ export default function LoginPage() {
body: JSON.stringify({ email, password }) body: JSON.stringify({ email, password })
}); });
if (!res.ok) { if (!res.ok) {
setError("Giriş başarısız. Demo hesap için aşağıdaki butonu kullanabilir veya bilgileri elle girebilirsiniz (demo@winninghunter.local / demo1234)."); setError("E-posta veya şifre hatalı.");
return;
}
location.href = "/dashboard/ads";
}
async function loginAsDemo(e: React.MouseEvent) {
e.preventDefault();
setError("");
const demoEmail = "demo@winninghunter.local";
const demoPassword = "demo1234";
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ email: demoEmail, password: demoPassword })
});
if (!res.ok) {
setError("Demo girişi başarısız. Demo veritabanı kurulmamış olabilir.");
return; return;
} }
location.href = "/dashboard/ads"; location.href = "/dashboard/ads";
@@ -54,7 +37,7 @@ export default function LoginPage() {
<main className="grid min-h-screen place-items-center bg-slate-50 px-4"> <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"> <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> <h1 className="text-3xl font-black">Giriş yap</h1>
<p className="mt-2 text-sm text-slate-500">Kendi oluşturduğunuz hesapla veya hazır demo hesapla giriş yapabilirsiniz.</p> <p className="mt-2 text-sm text-slate-500">Hesabınızla veya Google üzerinden güvenli giriş yapın.</p>
<label className="mt-6 block text-sm font-bold">E-posta</label> <label className="mt-6 block text-sm font-bold">E-posta</label>
<input <input
@@ -93,14 +76,6 @@ export default function LoginPage() {
Giriş yap Giriş yap
</button> </button>
<button
type="button"
onClick={loginAsDemo}
className="mt-3 w-full rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 font-semibold text-slate-700 hover:bg-slate-100 transition"
>
Hazır Demo Hesapla Giriş Yap
</button>
<div className="mt-6 text-center text-sm"> <div className="mt-6 text-center text-sm">
<span className="text-slate-500">Hesabınız yok mu? </span> <span className="text-slate-500">Hesabınız yok mu? </span>
<a href="/register" className="font-semibold text-violet-700 hover:underline"> <a href="/register" className="font-semibold text-violet-700 hover:underline">
+1 -1
View File
@@ -23,7 +23,7 @@ export default function LandingPage() {
</p> </p>
<div className="mt-8 flex flex-wrap gap-3"> <div className="mt-8 flex flex-wrap gap-3">
<LinkButton href="/register" className="px-6 py-3">Ücretsiz başla</LinkButton> <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> <LinkButton href="/dashboard/ads" className="bg-slate-950 px-6 py-3 hover:bg-slate-800">Panele git</LinkButton>
</div> </div>
</div> </div>
<div className="glass rounded-[2rem] p-4 shadow-2xl"> <div className="glass rounded-[2rem] p-4 shadow-2xl">
+2 -1
View File
@@ -1,6 +1,7 @@
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { LinkButton } from "@/components/ui/button"; import { LinkButton } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { CheckoutButton } from "@/components/checkout-button";
export default async function PricingPage() { export default async function PricingPage() {
const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []); const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []);
@@ -29,7 +30,7 @@ export default async function PricingPage() {
<li>Store tracker: {String((plan.features as any).trackedStores ?? "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> <li>Saved ads: {String((plan.features as any).savedAds ?? "Sınırsız")}</li>
</ul> </ul>
<LinkButton href="/register" className="mt-6 w-full">Başla</LinkButton> {plan.code === "FREE" ? <LinkButton href="/register" className="mt-6 w-full">Ücretsiz başla</LinkButton> : <CheckoutButton planCode={plan.code} />}
</Card> </Card>
))} ))}
</div> </div>
+1 -1
View File
@@ -57,7 +57,7 @@ export default function RegisterPage() {
<label className="mt-4 block text-sm font-bold">Şifre</label> <label className="mt-4 block text-sm font-bold">Şifre</label>
<input <input
type="password" type="password"
placeholder="En az 6 karakterli şifreniz" placeholder="En az 10 karakterli şifreniz"
className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600" className="mt-2 w-full rounded-xl border border-slate-200 px-4 py-3 focus:outline-none focus:ring-2 focus:ring-violet-600"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
+7 -13
View File
@@ -6,7 +6,7 @@ import { useRouter } from "next/navigation";
const RESULT_LIMIT = 10; const RESULT_LIMIT = 10;
const RESULT_OPTIONS = [10, 25, 50, 100]; const RESULT_OPTIONS = [10, 25, 50, 100];
export function ApifyEmptySearch({ query }: { query: string }) { export function ApifyEmptySearch({ query, planLimit }: { query: string; planLimit: number }) {
const router = useRouter(); const router = useRouter();
const [maxResults, setMaxResults] = useState(RESULT_LIMIT); const [maxResults, setMaxResults] = useState(RESULT_LIMIT);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -17,18 +17,12 @@ export function ApifyEmptySearch({ query }: { query: string }) {
setBusy(true); setBusy(true);
setMessage(""); setMessage("");
try { try {
const response = await fetch("/api/admin/ingest/apify", { const response = await fetch("/api/ads/import-apify", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
searchTerms: [query], searchTerm: query,
country: "ALL",
adActiveStatus: "ACTIVE",
mediaType: "ALL",
maxResults, maxResults,
maxCostUsd: Math.max(0.1, Math.ceil(maxResults * 0.004 * 10) / 10),
scrapeAdDetails: true,
includeAboutPage: false
}) })
}); });
const data = await response.json().catch(() => ({})); const data = await response.json().catch(() => ({}));
@@ -60,18 +54,18 @@ export function ApifyEmptySearch({ query }: { query: string }) {
onChange={(event) => setMaxResults(Number(event.target.value))} onChange={(event) => setMaxResults(Number(event.target.value))}
className="min-w-0 flex-1 rounded-2xl border border-violet-200 bg-white px-4 py-3 font-bold" className="min-w-0 flex-1 rounded-2xl border border-violet-200 bg-white px-4 py-3 font-bold"
> >
{RESULT_OPTIONS.map((count) => <option key={count} value={count}>{count} reklam</option>)} {RESULT_OPTIONS.filter((count) => count <= planLimit).map((count) => <option key={count} value={count}>{count} reklam</option>)}
</select> </select>
<button <button
type="button" type="button"
disabled={busy} disabled={busy || planLimit === 0}
onClick={importFromApify} onClick={importFromApify}
className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50" className="rounded-2xl bg-violet-700 px-5 py-3 font-black text-white disabled:cursor-wait disabled:opacity-50"
> >
{busy ? "Getiriliyor…" : "Getir"} {busy ? "Getiriliyor…" : planLimit === 0 ? "Planı yükselt" : "Getir"}
</button> </button>
</div> </div>
<p className="mt-2 text-xs text-slate-500">Apify · Yalnızca yöneticiler</p> <p className="mt-2 text-xs text-slate-500">Apify · Plan limiti: {planLimit || "erişim yok"} reklam · Kullanılan kayıt kadar API kredisi</p>
{message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>} {message && <p className={`mt-3 text-sm font-semibold ${error ? "text-rose-600" : "text-emerald-700"}`}>{message}</p>}
</div> </div>
); );
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useState } from "react";
export function CheckoutButton({ planCode }: { planCode: "BASIC" | "STANDARD" | "PREMIUM" }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function checkout() {
setBusy(true); setError("");
try {
const response = await fetch("/api/billing/checkout", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ planCode, interval: "monthly" }) });
const data = await response.json().catch(() => ({}));
if (response.status === 401) { window.location.href = "/login?next=/pricing"; return; }
if (!response.ok || !data.url) throw new Error(data.error || "CHECKOUT_FAILED");
window.location.href = data.url;
} catch (caught) { setError(caught instanceof Error ? caught.message : "Ödeme başlatılamadı."); setBusy(false); }
}
return <><button type="button" onClick={checkout} disabled={busy} className="mt-6 w-full rounded-xl bg-violet-700 px-4 py-2 font-bold text-white disabled:opacity-50">{busy ? "Yönlendiriliyor…" : "Satın al"}</button>{error && <p className="mt-2 text-xs font-semibold text-rose-600">{error}</p>}</>;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
const items = [
["Ads", "/dashboard/ads"], ["Stores", "/dashboard/stores"], ["Store Tracker", "/dashboard/store-tracker"],
["Saved Ads", "/dashboard/saved-ads"], ["TikTok Shop", "/dashboard/tiktok-shop"], ["Magic AI", "/dashboard/magic-ai"],
["Trends", "/dashboard/trends"], ["Brand Tracker", "/dashboard/brand-tracker"], ["Account", "/dashboard/account"]
];
export function DashboardMobileNav({ isAdmin }: { isAdmin: boolean }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
return <div className="lg:hidden"><button type="button" onClick={() => setOpen((value) => !value)} aria-expanded={open} aria-controls="mobile-dashboard-nav" className="rounded-xl border border-slate-200 px-3 py-2 text-sm font-bold">{open ? "Kapat" : "Menü"}</button>{open && <nav id="mobile-dashboard-nav" className="absolute inset-x-4 top-[72px] grid gap-1 rounded-2xl border border-slate-200 bg-white p-3 shadow-xl">{items.map(([label, href]) => <Link key={href} href={href} onClick={() => setOpen(false)} className={`rounded-xl px-3 py-2 text-sm font-semibold ${pathname === href ? "bg-violet-50 text-violet-700" : "text-slate-700 hover:bg-slate-50"}`}>{label}</Link>)}{isAdmin && <Link href="/dashboard/admin" onClick={() => setOpen(false)} className="rounded-xl bg-slate-950 px-3 py-2 text-sm font-bold text-white">Admin</Link>}</nav>}</div>;
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function MagicAiRunner() {
const router = useRouter();
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
async function analyze() {
setBusy(true); setMessage("");
try {
const response = await fetch("/api/magic-ai/analyze", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query: query.trim() || undefined }) });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "AI_ANALYSIS_FAILED");
setMessage("Yeni AI analizi kaydedildi."); router.refresh();
} catch (error) { setMessage(error instanceof Error ? error.message : "Analiz başarısız."); }
finally { setBusy(false); }
}
return <div className="mb-5 flex flex-wrap gap-3 rounded-3xl bg-white p-4 shadow-soft"><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Ürün, marka veya reklam metni" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" /><button type="button" onClick={analyze} disabled={busy} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy ? "AI analiz ediyor…" : "Gerçek AI analizi"}</button>{message && <p className="w-full text-sm font-semibold text-violet-700">{message}</p>}</div>;
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
type TrackedStoreItem = {
id: string;
store: {
id: string;
name: string;
domain: string;
estRevenue30dMax: number | null;
products: { title: string }[];
};
};
export function StoreTrackerManager({ tracked, limit }: { tracked: TrackedStoreItem[]; limit: number | null }) {
const router = useRouter();
const [domain, setDomain] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [error, setError] = useState(false);
async function addStore(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setBusy("add"); setMessage("");
try {
const response = await fetch("/api/tracked-stores", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ domain })
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED");
setDomain(""); setError(false); setMessage("Mağaza takip listesine eklendi."); router.refresh();
} catch (caught) {
setError(true); setMessage(caught instanceof Error ? caught.message : "Mağaza eklenemedi.");
} finally { setBusy(null); }
}
async function removeStore(id: string) {
setBusy(id); setMessage("");
try {
const response = await fetch(`/api/tracked-stores/${id}`, { method: "DELETE" });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "STORE_TRACKER_FAILED");
setError(false); setMessage("Mağaza takip listesinden çıkarıldı."); router.refresh();
} catch (caught) {
setError(true); setMessage(caught instanceof Error ? caught.message : "Mağaza çıkarılamadı.");
} finally { setBusy(null); }
}
return (
<div>
<form onSubmit={addStore} className="mb-4 flex flex-col gap-3 sm:flex-row">
<input value={domain} onChange={(event) => setDomain(event.target.value)} required placeholder="ornek-magaza.com" className="min-w-0 flex-1 rounded-2xl border border-slate-200 px-4 py-3" />
<button disabled={busy !== null} className="rounded-2xl bg-slate-950 px-5 py-3 font-bold text-white disabled:opacity-50">{busy === "add" ? "Ekleniyor…" : "Mağazayı takip et"}</button>
</form>
<div className="mb-5 flex flex-wrap items-center justify-between gap-2 text-sm"><span className="text-slate-500">Yalnızca WinningHunter veritabanında bulunan domainler eklenebilir.</span><span className="rounded-full bg-violet-50 px-3 py-1 font-bold text-violet-700">{tracked.length} / {limit === null ? "∞" : limit}</span></div>
{message && <div className={`mb-4 rounded-xl p-3 text-sm font-semibold ${error ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700"}`}>{message}</div>}
<div className="space-y-3">
{tracked.map((item) => <div key={item.id} className="flex flex-col gap-3 rounded-2xl bg-slate-50 p-4 sm:flex-row sm:items-center sm:justify-between"><a href={`/dashboard/stores/${item.store.id}`} className="min-w-0"><div className="truncate font-black">{item.store.name}</div><div className="truncate text-sm text-slate-500">{item.store.domain} · {item.store.products.map((product) => product.title).join(", ") || "Bestseller yok"} · {item.store.estRevenue30dMax?.toLocaleString() || "—"} est.</div></a><button type="button" disabled={busy !== null} onClick={() => removeStore(item.id)} className="shrink-0 rounded-xl border border-rose-200 px-4 py-2 text-sm font-bold text-rose-700 disabled:opacity-50">{busy === item.id ? "Çıkarılıyor…" : "Takibi bırak"}</button></div>)}
{!tracked.length && <div className="rounded-2xl bg-amber-50 p-4 text-amber-800">Henüz takip edilen mağaza yok.</div>}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function TikTokShopImport({ planLimit }: { planLimit: number }) {
const router = useRouter();
const [query, setQuery] = useState("");
const [region, setRegion] = useState("US");
const [count, setCount] = useState(Math.min(10, planLimit || 10));
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
async function run() {
setBusy(true); setMessage("");
try {
const response = await fetch("/api/tiktok-shop/import", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ query, region, maxResults: count }) });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || "TIKTOK_IMPORT_FAILED");
setMessage(`${data.imported} ürün güncellendi.`); router.refresh();
} catch (error) { setMessage(error instanceof Error ? error.message : "İçe aktarma başarısız."); }
finally { setBusy(false); }
}
return <div className="mb-5 grid gap-3 rounded-3xl bg-white p-4 shadow-soft md:grid-cols-[1fr_120px_130px_130px]">
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="TikTok Shop ürününü canlı ara" className="rounded-2xl border border-slate-200 px-4 py-3" />
<select value={region} onChange={(e) => setRegion(e.target.value)} className="rounded-2xl border border-slate-200 px-3"><option>US</option><option>GB</option><option>DE</option><option>FR</option><option>TR</option></select>
<select value={count} onChange={(e) => setCount(Number(e.target.value))} className="rounded-2xl border border-slate-200 px-3">{[10,25,50].filter((v) => v <= planLimit).map((v) => <option key={v} value={v}>{v} ürün</option>)}</select>
<button type="button" onClick={run} disabled={busy || planLimit === 0 || query.trim().length < 2} className="rounded-2xl bg-slate-950 px-4 py-3 font-bold text-white disabled:opacity-50">{busy ? "Getiriliyor…" : planLimit ? "Apifydan getir" : "Planı yükselt"}</button>
{message && <p className="text-sm font-semibold text-violet-700 md:col-span-4">{message}</p>}
</div>;
}
+103
View File
@@ -0,0 +1,103 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
type Row = Record<string, unknown>;
const API_BASE = "https://api.apify.com/v2";
const DEFAULT_ACTOR = "toolzerhub~tiktok-shop-products-scraper";
function text(row: Row, ...keys: string[]) {
for (const key of keys) {
const value = row[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function number(row: Row, ...keys: string[]) {
for (const key of keys) {
const value = row[key];
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value.replace(/[^0-9.-]/g, "")) : NaN;
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function nested(row: Row, key: string) {
const value = row[key];
return value && typeof value === "object" && !Array.isArray(value) ? value as Row : {};
}
function safeUrl(value: string | null) {
if (!value) return null;
try {
const url = new URL(value);
return ["https:", "http:"].includes(url.protocol) ? url.toString() : null;
} catch { return null; }
}
export async function runTikTokShopActor(input: { query: string; region: string; maxResults: number }) {
const token = process.env.APIFY_TOKEN?.trim();
if (!token) throw new Error("APIFY_NOT_CONFIGURED");
const actor = (process.env.APIFY_TIKTOK_ACTOR_ID?.trim() || DEFAULT_ACTOR).replace("/", "~");
if (!/^[a-zA-Z0-9_-]+~[a-zA-Z0-9_-]+$/.test(actor)) throw new Error("APIFY_TIKTOK_ACTOR_INVALID");
const params = new URLSearchParams({ clean: "true", timeout: "180", maxItems: String(input.maxResults), maxTotalChargeUsd: String(Math.max(0.1, input.maxResults * 0.01)) });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 210_000);
try {
const response = await fetch(`${API_BASE}/actors/${actor}/run-sync-get-dataset-items?${params}`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
body: JSON.stringify({ search_word: input.query, region: input.region, maxItems: input.maxResults, enrichProductDetails: false }),
signal: controller.signal,
cache: "no-store"
});
if (!response.ok) throw new Error(`APIFY_TIKTOK_HTTP_${response.status}`);
const payload: unknown = await response.json();
if (!Array.isArray(payload)) throw new Error("APIFY_TIKTOK_INVALID_RESPONSE");
return payload.filter((item): item is Row => Boolean(item) && typeof item === "object" && !Array.isArray(item));
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error("APIFY_TIKTOK_TIMEOUT");
throw error;
} finally { clearTimeout(timer); }
}
export async function importTikTokProducts(rows: Row[], region: string) {
let imported = 0;
let failed = 0;
for (const row of rows) {
const product = nested(row, "product");
const shop = { ...nested(row, "shop"), ...nested(row, "seller") };
const externalId = text(row, "productId", "product_id", "id") || text(product, "productId", "product_id", "id");
const title = text(row, "title", "productTitle", "name") || text(product, "title", "name");
if (!externalId || !title) { failed += 1; continue; }
try {
await prisma.tikTokProduct.upsert({
where: { externalId },
create: {
externalId, title, description: text(row, "description") || text(product, "description"),
productUrl: safeUrl(text(row, "productUrl", "url") || text(product, "productUrl", "url")),
imageUrl: safeUrl(text(row, "image", "imageUrl", "thumbnail") || text(product, "image", "imageUrl", "thumbnail")),
shopName: text(row, "shopName", "sellerName") || text(shop, "shopName", "sellerName", "name"),
shopExternalId: text(row, "sellerId", "shopId") || text(shop, "sellerId", "shopId", "id"),
region: text(row, "region", "country") || region, currency: text(row, "currency") || text(product, "currency"),
price: number(row, "price", "salePrice") ?? number(product, "price", "salePrice"),
originalPrice: number(row, "originalPrice", "listPrice") ?? number(product, "originalPrice", "listPrice"),
soldCount: Math.round(number(row, "soldCount", "sales", "unitsSold") ?? number(product, "soldCount", "sales", "unitsSold") ?? 0),
rating: number(row, "rating", "ratingScore") ?? number(product, "rating", "ratingScore"),
reviewCount: Math.round(number(row, "reviewCount", "reviews") ?? number(product, "reviewCount", "reviews") ?? 0),
category: text(row, "category", "categoryName") || text(product, "category", "categoryName"), raw: row as Prisma.InputJsonValue
},
update: {
title, productUrl: safeUrl(text(row, "productUrl", "url") || text(product, "productUrl", "url")),
imageUrl: safeUrl(text(row, "image", "imageUrl", "thumbnail") || text(product, "image", "imageUrl", "thumbnail")),
shopName: text(row, "shopName", "sellerName") || text(shop, "shopName", "sellerName", "name"),
price: number(row, "price", "salePrice") ?? number(product, "price", "salePrice"),
soldCount: Math.round(number(row, "soldCount", "sales", "unitsSold") ?? number(product, "soldCount", "sales", "unitsSold") ?? 0),
rating: number(row, "rating", "ratingScore") ?? number(product, "rating", "ratingScore"), lastSeenAt: new Date(), raw: row as Prisma.InputJsonValue
}
});
imported += 1;
} catch { failed += 1; }
}
return { imported, failed };
}
+67
View File
@@ -0,0 +1,67 @@
type AdSignal = { headline: string | null; primaryText: string | null; mediaType: string; daysRunning: number | null; status: string; brand: string | null };
export type CreativeAnalysis = { summary: string; insights: Array<{ title: string; action: string; confidence: number }> };
export async function analyzeCreatives(query: string | undefined, ads: AdSignal[]): Promise<CreativeAnalysis & { model: string }> {
const apiKey = process.env.OPENAI_API_KEY?.trim();
if (!apiKey) throw new Error("OPENAI_NOT_CONFIGURED");
const model = process.env.OPENAI_MODEL?.trim() || "gpt-5.6-luna";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 45_000);
try {
const response = await fetch("https://api.openai.com/v1/responses", {
method: "POST",
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify({
model,
input: [
{ role: "system", content: [{ type: "input_text", text: "Sen bir reklam kreatif stratejistisin. Sadece verilen sinyallere dayan. Türkçe, kısa, somut A/B test önerileri üret. Veri yoksa uydurma." }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify({ query: query || null, ads }) }] }
],
text: {
format: {
type: "json_schema",
name: "creative_analysis",
strict: true,
schema: {
type: "object",
additionalProperties: false,
properties: {
summary: { type: "string" },
insights: {
type: "array",
minItems: 1,
maxItems: 6,
items: {
type: "object",
additionalProperties: false,
properties: {
title: { type: "string" },
action: { type: "string" },
confidence: { type: "number", minimum: 0, maximum: 1 }
},
required: ["title", "action", "confidence"]
}
}
},
required: ["summary", "insights"]
}
}
},
max_output_tokens: 1200
}),
signal: controller.signal,
cache: "no-store"
});
if (!response.ok) throw new Error(`OPENAI_HTTP_${response.status}`);
const payload = await response.json() as { output_text?: string; output?: Array<{ content?: Array<{ type?: string; text?: string }> }> };
const outputText = payload.output_text || payload.output?.flatMap((item) => item.content || []).find((item) => item.type === "output_text")?.text;
if (!outputText) throw new Error("OPENAI_INVALID_RESPONSE");
const parsed = JSON.parse(outputText) as CreativeAnalysis;
if (!parsed.summary || !Array.isArray(parsed.insights)) throw new Error("OPENAI_INVALID_RESPONSE");
return { ...parsed, model };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error("OPENAI_TIMEOUT");
throw error;
} finally { clearTimeout(timer); }
}
+31 -2
View File
@@ -102,11 +102,25 @@ export async function checkAndConsumeQuota(params: {
}; };
} }
const updated = await prisma.usageCounter.update({ // Koşullu artış, eşzamanlı iki isteğin aynı kalan kotayı tüketmesini önler.
where: { id: counter.id }, const claimed = await prisma.usageCounter.updateMany({
where: { id: counter.id, used: { lte: limit - amount } },
data: { used: { increment: amount }, limit, resetAt } data: { used: { increment: amount }, limit, resetAt }
}); });
if (claimed.count === 0) {
const latest = await prisma.usageCounter.findUniqueOrThrow({ where: { id: counter.id } });
return {
allowed: false,
used: latest.used,
limit,
remaining: Math.max(0, limit - latest.used),
resetAt: latest.resetAt
};
}
const updated = await prisma.usageCounter.findUniqueOrThrow({ where: { id: counter.id } });
return { return {
allowed: true, allowed: true,
used: updated.used, used: updated.used,
@@ -115,3 +129,18 @@ export async function checkAndConsumeQuota(params: {
resetAt: updated.resetAt resetAt: updated.resetAt
}; };
} }
export async function refundQuota(params: { userId: string; metric: string; amount?: number }) {
const amount = Math.max(0, params.amount ?? 1);
if (amount === 0) return;
const period = periodForMetric(params.metric);
const periodKey = keyForPeriod(period);
const counter = await prisma.usageCounter.findUnique({
where: { userId_metric_periodKey: { userId: params.userId, metric: params.metric, periodKey } }
});
if (!counter) return;
await prisma.usageCounter.update({
where: { id: counter.id },
data: { used: Math.max(0, counter.used - amount) }
});
}
+14
View File
@@ -0,0 +1,14 @@
import Stripe from "stripe";
export function stripeClient() {
const key = process.env.STRIPE_SECRET_KEY?.trim();
if (!key) throw new Error("STRIPE_NOT_CONFIGURED");
return new Stripe(key, { maxNetworkRetries: 2, timeout: 20_000 });
}
export function stripePriceFor(planCode: string, interval: "monthly" | "yearly") {
const key = `STRIPE_PRICE_${planCode}_${interval}`.toUpperCase();
const price = process.env[key]?.trim();
if (!price || !/^price_[a-zA-Z0-9]+$/.test(price)) throw new Error("STRIPE_PRICE_NOT_CONFIGURED");
return price;
}
+49
View File
@@ -0,0 +1,49 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
function startOfUtcDay() {
const now = new Date();
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
export async function captureTrendSnapshot() {
const [ads, stores, tiktok] = await Promise.all([
prisma.ad.findMany({ select: { niche: true, mediaType: true, countries: true, status: true, daysRunning: true, firstSeenAt: true } }),
prisma.store.aggregate({ _count: true, _avg: { monthlyVisitGrowth: true, monthlyVisits: true } }),
prisma.tikTokProduct.aggregate({ _count: true, _sum: { soldCount: true }, _avg: { rating: true, price: true } })
]);
const count = (values: string[]) => Object.fromEntries([...values.reduce((map, value) => map.set(value || "Bilinmiyor", (map.get(value || "Bilinmiyor") || 0) + 1), new Map<string, number>()).entries()].sort((a, b) => b[1] - a[1]).slice(0, 20));
const metrics = {
ads: { total: ads.length, active: ads.filter((ad) => ad.status === "ACTIVE").length, longRunners: ads.filter((ad) => (ad.daysRunning || 0) >= 30).length, new14d: ads.filter((ad) => ad.firstSeenAt && Date.now() - ad.firstSeenAt.getTime() <= 14 * 86_400_000).length },
niches: count(ads.map((ad) => ad.niche || "Bilinmiyor")), media: count(ads.map((ad) => ad.mediaType)), countries: count(ads.flatMap((ad) => ad.countries)),
stores: { total: stores._count, avgGrowth: stores._avg.monthlyVisitGrowth, avgVisits: stores._avg.monthlyVisits },
tiktok: { total: tiktok._count, sold: tiktok._sum.soldCount, avgRating: tiktok._avg.rating, avgPrice: tiktok._avg.price }
};
return prisma.trendSnapshot.upsert({ where: { date: startOfUtcDay() }, create: { date: startOfUtcDay(), metrics: metrics as Prisma.InputJsonValue }, update: { metrics: metrics as Prisma.InputJsonValue } });
}
async function sendAlertEmail(email: string, title: string, brand: string) {
const key = process.env.RESEND_API_KEY?.trim();
const from = process.env.ALERT_FROM_EMAIL?.trim();
if (!key || !from) return false;
const response = await fetch("https://api.resend.com/emails", { method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" }, body: JSON.stringify({ from, to: [email], subject: `${brand}: yeni reklam`, html: `<p><strong>${brand}</strong> için yeni reklam bulundu.</p><p>${title}</p><p><a href="${process.env.NEXT_PUBLIC_APP_URL || ""}/dashboard/brand-tracker">WinningHunter'da görüntüle</a></p>` }) });
return response.ok;
}
export async function createBrandAlerts() {
const tracked = await prisma.trackedBrand.findMany({ include: { user: { select: { email: true } }, brandPage: { include: { ads: { orderBy: { createdAt: "desc" }, take: 20 } } } } });
let created = 0;
for (const item of tracked) {
for (const ad of item.brandPage.ads) {
if (ad.createdAt < item.createdAt && (!ad.firstSeenAt || ad.firstSeenAt < item.createdAt)) continue;
const title = ad.headline || ad.primaryText?.slice(0, 100) || "Yeni kreatif";
const unique = { userId: item.userId, trackedBrandId: item.id, adId: ad.id, type: "NEW_AD" };
if (await prisma.brandAlert.findUnique({ where: { userId_trackedBrandId_adId_type: unique } })) continue;
await prisma.brandAlert.create({ data: { ...unique, title, details: { brand: item.brandPage.name, mediaType: ad.mediaType } } });
await prisma.notification.create({ data: { userId: item.userId, type: "BRAND_NEW_AD", title: `${item.brandPage.name}: yeni reklam`, body: title, link: "/dashboard/brand-tracker" } });
await sendAlertEmail(item.user.email, title, item.brandPage.name).catch(() => false);
created += 1;
}
}
return created;
}
+51
View File
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";
import { MediaType } from "@prisma/client";
import { normalizeApifyAd } from "../src/lib/apify";
import { getFeatureLimit } from "../src/lib/plans";
import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit";
import { stripePriceFor } from "../src/lib/stripe";
test("Apify reklamı metin, medya ve ülke alanlarıyla normalize edilir", () => {
const ad = normalizeApifyAd({
adArchiveID: "123",
pageID: "page-1",
pageName: "Berber İstanbul",
adText: "Yeni stilini keşfet",
videoUrl: "https://cdn.example.com/ad.mp4",
countries: ["TR"],
adStatus: "ACTIVE"
});
assert.ok(ad);
assert.equal(ad.externalAdId, "123");
assert.equal(ad.mediaType, MediaType.VIDEO);
assert.deepEqual(ad.countries, ["TR"]);
});
test("plan feature limiti metrik adına göre çözülür", () => {
const plan = { features: { adsSearchDaily: 100, apiCreditsMonthly: 500 } } as never;
assert.equal(getFeatureLimit(plan, "ads_search_daily"), 100);
assert.equal(getFeatureLimit(plan, "api_credits_monthly"), 500);
assert.equal(getFeatureLimit(plan, "unknown"), 0);
});
test("istemci IP'sinde güvenilir son proxy adresi kullanılır", () => {
const request = new Request("https://example.com", { headers: { "x-forwarded-for": "198.51.100.1, 10.0.0.2" } });
assert.equal(istemciIp(request), "10.0.0.2");
});
test("giriş hız sınırı on birinci isteği engeller", () => {
const request = new Request("https://example.com", { headers: { "x-real-ip": `test-${Date.now()}` } });
for (let i = 0; i < 10; i += 1) assert.equal(hizSiniriAsimi(request, "giris"), null);
assert.equal(hizSiniriAsimi(request, "giris")?.status, 429);
});
test("Stripe fiyat kimliği env allowlist'inden okunur", () => {
const previous = process.env.STRIPE_PRICE_BASIC_MONTHLY;
process.env.STRIPE_PRICE_BASIC_MONTHLY = "price_test123";
assert.equal(stripePriceFor("BASIC", "monthly"), "price_test123");
process.env.STRIPE_PRICE_BASIC_MONTHLY = "invalid";
assert.throws(() => stripePriceFor("BASIC", "monthly"), /STRIPE_PRICE_NOT_CONFIGURED/);
if (previous === undefined) delete process.env.STRIPE_PRICE_BASIC_MONTHLY;
else process.env.STRIPE_PRICE_BASIC_MONTHLY = previous;
});