From 2b75e97ad6538ac7cfa995c300a27614faa04371 Mon Sep 17 00:00:00 2001 From: Hikmet Date: Sun, 9 Aug 2026 09:24:26 +0300 Subject: [PATCH] feat: arastirma modulleri odeme ve operasyonlari tamamla --- .env.example | 22 + .env.production.example | 16 + README.md | 29 +- docker-compose.yml | 29 +- next.config.mjs | 12 + package-lock.json | 440 +++++++++++-------- package.json | 12 +- prisma/schema.prisma | 97 +++- prisma/seed-if-empty.ts | 4 + scripts/backup-postgres.sh | 11 + scripts/monitor-health.sh | 14 + src/app/api/admin/ingest/seed-demo/route.ts | 8 - src/app/api/ads/import-apify/route.ts | 97 ++++ src/app/api/auth/login/route.ts | 6 +- src/app/api/auth/register/route.ts | 6 +- src/app/api/billing/checkout/route.ts | 45 ++ src/app/api/billing/webhook/route.ts | 54 +++ src/app/api/cron/daily-intelligence/route.ts | 14 + src/app/api/health/route.ts | 12 + src/app/api/magic-ai/analyze/route.ts | 30 ++ src/app/api/tiktok-shop/import/route.ts | 44 ++ src/app/api/tracked-stores/route.ts | 12 +- src/app/dashboard/ads/page.tsx | 9 +- src/app/dashboard/brand-tracker/page.tsx | 6 +- src/app/dashboard/layout.tsx | 4 +- src/app/dashboard/magic-ai/page.tsx | 11 +- src/app/dashboard/store-tracker/page.tsx | 12 +- src/app/dashboard/tiktok-shop/page.tsx | 60 +-- src/app/dashboard/trends/page.tsx | 6 +- src/app/login/page.tsx | 29 +- src/app/page.tsx | 2 +- src/app/pricing/page.tsx | 3 +- src/app/register/page.tsx | 2 +- src/components/ads/apify-empty-search.tsx | 20 +- src/components/checkout-button.tsx | 18 + src/components/dashboard-mobile-nav.tsx | 16 + src/components/magic-ai-runner.tsx | 21 + src/components/store-tracker-manager.tsx | 67 +++ src/components/tiktok-shop-import.tsx | 29 ++ src/lib/apify-tiktok.ts | 103 +++++ src/lib/openai-analysis.ts | 67 +++ src/lib/quota.ts | 33 +- src/lib/stripe.ts | 14 + src/lib/trend-snapshots.ts | 49 +++ tests/core.test.ts | 51 +++ 45 files changed, 1316 insertions(+), 330 deletions(-) create mode 100755 scripts/backup-postgres.sh create mode 100755 scripts/monitor-health.sh delete mode 100644 src/app/api/admin/ingest/seed-demo/route.ts create mode 100644 src/app/api/ads/import-apify/route.ts create mode 100644 src/app/api/billing/checkout/route.ts create mode 100644 src/app/api/billing/webhook/route.ts create mode 100644 src/app/api/cron/daily-intelligence/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/magic-ai/analyze/route.ts create mode 100644 src/app/api/tiktok-shop/import/route.ts create mode 100644 src/components/checkout-button.tsx create mode 100644 src/components/dashboard-mobile-nav.tsx create mode 100644 src/components/magic-ai-runner.tsx create mode 100644 src/components/store-tracker-manager.tsx create mode 100644 src/components/tiktok-shop-import.tsx create mode 100644 src/lib/apify-tiktok.ts create mode 100644 src/lib/openai-analysis.ts create mode 100644 src/lib/stripe.ts create mode 100644 src/lib/trend-snapshots.ts create mode 100644 tests/core.test.ts diff --git a/.env.example b/.env.example index d92d2d3..c61c7e5 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,25 @@ GOOGLE_CLIENT_SECRET= # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= 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 diff --git a/.env.production.example b/.env.production.example index 9c8ec67..c740615 100644 --- a/.env.production.example +++ b/.env.production.example @@ -12,6 +12,22 @@ GOOGLE_CLIENT_SECRET= # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= 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 POSTGRES_PASSWORD="change-this-long-random-password" diff --git a/README.md b/README.md index 336c987..5670763 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,8 @@ npm run db:seed npm run dev ``` -Demo hesaplar: - -```txt -Demo: -demo@winninghunter.local / demo1234 - -Admin: -admin@winninghunter.local / admin1234 -``` +Üretimde hazır demo hesabı oluşturulmaz. Yalnızca yerel geliştirmede bilinçli olarak +`ENABLE_DEMO_ACCOUNTS=true` ayarlanırsa seed hesapları korunur. ## Production build @@ -53,9 +46,16 @@ curl http://localhost:3001/login ```bash cp .env.production.example .env 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 1. Yeni proje oluştur. @@ -69,15 +69,10 @@ NEXT_PUBLIC_APP_URL=https://winninghunter.your-domain.com DATABASE_URL=postgresql://... AUTH_COOKIE_NAME=wh_session AUTH_SESSION_DAYS=30 -ADMIN_EMAILS=admin@winninghunter.local +ADMIN_EMAILS=owner@example.com ``` -6. İlk deploy sonrası terminal: - -```bash -npx prisma db push -npm run db:seed -``` +6. Uygulama başlangıcında şema, plan seed'i ve `ADMIN_EMAILS` yetkileri otomatik uygulanır. ## Vercel deploy diff --git a/docker-compose.yml b/docker-compose.yml index 77d53cd..8d47db0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,16 +24,37 @@ services: NODE_ENV: production NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} DATABASE_URL: postgresql://winninghunter:${POSTGRES_PASSWORD:-winninghunter_dev_password}@db:5432/winninghunter?schema=public - AUTH_COOKIE_NAME: wh_session - AUTH_SESSION_DAYS: 30 - ADMIN_EMAILS: admin@winninghunter.local + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-wh_session} + AUTH_SESSION_DAYS: ${AUTH_SESSION_DAYS:-30} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} 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} ports: - "${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: winninghunter_pg: diff --git a/next.config.mjs b/next.config.mjs index 38582b7..d69588f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,9 +1,21 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + poweredByHeader: false, typescript: { ignoreBuildErrors: false }, turbopack: { 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 }]; } }; diff --git a/package-lock.json b/package-lock.json index 0ca08c0..c25e23d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,10 @@ "dependencies": { "@prisma/client": "5.22.0", "bcryptjs": "2.4.3", - "next": "^16.3.0-canary.79", + "next": "^16.3.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "stripe": "^22.4.0", "zod": "3.23.8" }, "devDependencies": { @@ -21,7 +22,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "autoprefixer": "10.4.20", - "postcss": "8.4.47", + "postcss": "^8.5.26", "prisma": "5.22.0", "tailwindcss": "3.4.13", "tsx": "4.23.4", @@ -42,9 +43,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -504,9 +505,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -516,19 +517,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -538,19 +539,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "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": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -564,9 +584,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -580,9 +600,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -596,9 +616,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -612,9 +632,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -628,9 +648,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -644,9 +664,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -660,9 +680,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -676,9 +696,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -692,9 +712,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -708,9 +728,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -720,19 +740,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -742,19 +762,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -764,19 +784,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -786,19 +806,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -808,19 +828,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -830,19 +850,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -852,19 +872,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -874,38 +894,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "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": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -915,16 +951,16 @@ "win32" ], "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-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -934,16 +970,16 @@ "win32" ], "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-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -953,7 +989,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -999,15 +1035,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0-canary.79.tgz", - "integrity": "sha512-F2U7ygW1qpAFXMLOp2vok9V5VWR2aKoQWX9sOPYpLk4NOlkkdeY5UAt1PDfm6nMPagL81AYEauwSU6N3iwUOng==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0-canary.79.tgz", - "integrity": "sha512-A8I4zhxR5Dsvcbbnai1Mm/i5LxC/lm66tgI7I2tZbiLindg+lTKF7FwcAFyhnqZR89hNRGlxV0ZpCWPeQNZWEw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", "cpu": [ "arm64" ], @@ -1021,9 +1057,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0-canary.79.tgz", - "integrity": "sha512-28a+1M3+MLUgGZqZC64vqjdonSTBNJXt60CNw4ZXCOkxPfzvp0wkvKjqL8ZYdLBBHBwTC7tUlmeEsnl7PWTXHg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", "cpu": [ "x64" ], @@ -1037,9 +1073,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0-canary.79.tgz", - "integrity": "sha512-MJ0oEhaFxnJ8LBK1zNelNqKQph+ZaWeqLpYOcFdl/aXM4PHHbv+5M5KEVKrbbdjwM2t+wdXmgQ2iozyUbdpaPw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", "cpu": [ "arm64" ], @@ -1053,9 +1089,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0-canary.79.tgz", - "integrity": "sha512-CgjUs9aeJED98Zwl+bh+Ufo/M0ddPy+2BmupRt8SMaxJZa2wNNn4vognqPfHKr/pkiqr01X3hyuxNEk0CCyW5g==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", "cpu": [ "arm64" ], @@ -1069,9 +1105,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0-canary.79.tgz", - "integrity": "sha512-s2yPLT03R/gr/+FUMMecjgjsN1gUH0M6twOvIwhEMcL00LUB/q4RlVpHsVfAZ5DT3EV8GLI/XW2qhpJFaRQEyA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", "cpu": [ "x64" ], @@ -1085,9 +1121,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0-canary.79.tgz", - "integrity": "sha512-vhbTok2qmQ/cXl4XPoiI7wVAX0iYPdgGqPaP/YgQf3eyTBfU5Ej57mdsd2LFxD2ObTMzAhAC3VidnC6oFhFnPg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", "cpu": [ "x64" ], @@ -1101,9 +1137,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0-canary.79.tgz", - "integrity": "sha512-glPd/6XP6jiflTPSCTNGbMkZ7BbNy29nk9QOainug2nwgRHr2xCPxQQNyxwtedCNKjKqXMhLGX8HWMu8PjPpEg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", "cpu": [ "arm64" ], @@ -1117,9 +1153,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0-canary.79.tgz", - "integrity": "sha512-m0BfBDihZgAHjgl7tkQVyd4jAyVGi1Y4E9taFSmcPbxqFm2gEwdRQ85xdOUBMe8FDQpwIs0xcv1obulpw3VDdA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", "cpu": [ "x64" ], @@ -1258,7 +1294,7 @@ "version": "26.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -1869,9 +1905,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -1887,16 +1923,16 @@ } }, "node_modules/next": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0-canary.79.tgz", - "integrity": "sha512-rMWsAkNQ/+adD45tnz05D683vA/+mVBze7flZ2oV3qgSlPc5tviVlTnu18uz4eccO53yIuL8zd5sdvrMb4Us8w==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0-canary.79", + "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.5.10", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -1906,15 +1942,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0-canary.79", - "@next/swc-darwin-x64": "16.3.0-canary.79", - "@next/swc-linux-arm64-gnu": "16.3.0-canary.79", - "@next/swc-linux-arm64-musl": "16.3.0-canary.79", - "@next/swc-linux-x64-gnu": "16.3.0-canary.79", - "@next/swc-linux-x64-musl": "16.3.0-canary.79", - "@next/swc-win32-arm64-msvc": "16.3.0-canary.79", - "@next/swc-win32-x64-msvc": "16.3.0-canary.79", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -1940,9 +1976,9 @@ } }, "node_modules/next/node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -1959,7 +1995,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2064,9 +2100,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2084,8 +2120,8 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -2345,48 +2381,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/source-map-js": { @@ -2398,6 +2439,23 @@ "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": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2678,7 +2736,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 2c773f1..cf142c0 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "build": "prisma generate && next build", "start": "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:migrate": "prisma migrate dev", "db:push": "prisma db push", "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": { "seed": "tsx prisma/seed.ts" @@ -20,9 +23,10 @@ "dependencies": { "@prisma/client": "5.22.0", "bcryptjs": "2.4.3", - "next": "^16.3.0-canary.79", + "next": "^16.3.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "stripe": "^22.4.0", "zod": "3.23.8" }, "devDependencies": { @@ -31,7 +35,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "autoprefixer": "10.4.20", - "postcss": "8.4.47", + "postcss": "^8.5.26", "prisma": "5.22.0", "tailwindcss": "3.4.13", "tsx": "4.23.4", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0899ed..f2b82c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -130,6 +130,9 @@ model User { payments Payment[] @relation("PaymentOwner") manualPayments Payment[] @relation("PaymentActor") adminAuditLogs AdminAuditLog[] @relation("AuditActor") + aiAnalyses AiAnalysis[] + brandAlerts BrandAlert[] + notifications Notification[] @@index([email]) } @@ -223,8 +226,8 @@ model BrandPage { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull) - ads Ad[] + store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull) + ads Ad[] trackedBy TrackedBrand[] @@unique([source, externalPageId]) @@ -527,14 +530,100 @@ model TrackedBrand { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - brandPage BrandPage @relation(fields: [brandPageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + brandPage BrandPage @relation(fields: [brandPageId], references: [id], onDelete: Cascade) + alerts BrandAlert[] @@unique([userId, brandPageId]) @@index([userId]) @@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 { id String @id @default(cuid()) userId String diff --git a/prisma/seed-if-empty.ts b/prisma/seed-if-empty.ts index c1aa603..e4761a3 100644 --- a/prisma/seed-if-empty.ts +++ b/prisma/seed-if-empty.ts @@ -30,6 +30,10 @@ async function main() { console.log("🌱 Empty database detected. Running demo seed..."); 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() diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100755 index 0000000..0912a07 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -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" diff --git a/scripts/monitor-health.sh b/scripts/monitor-health.sh new file mode 100755 index 0000000..1625edd --- /dev/null +++ b/scripts/monitor-health.sh @@ -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 diff --git a/src/app/api/admin/ingest/seed-demo/route.ts b/src/app/api/admin/ingest/seed-demo/route.ts deleted file mode 100644 index 833c40e..0000000 --- a/src/app/api/admin/ingest/seed-demo/route.ts +++ /dev/null @@ -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" }); -} diff --git a/src/app/api/ads/import-apify/route.ts b/src/app/api/ads/import-apify/route.ts new file mode 100644 index 0000000..5a7289b --- /dev/null +++ b/src/app/api/ads/import-apify/route.ts @@ -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 }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ace45d3..986d1dd 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -5,10 +5,12 @@ import { prisma } from "@/lib/db"; import { createSession } from "@/lib/auth/session"; 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) { - 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. const sinir = hizSiniriAsimi(req, "giris", body.email.trim().toLowerCase()); if (sinir) return sinir; diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index b455e0a..c3bd77d 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -9,7 +9,7 @@ import { hizSiniriAsimi } from "@/lib/rate-limit"; const schema = z.object({ email: z.string().email(), - password: z.string().min(6), + password: z.string().min(10).max(72), name: z.string().optional() }); @@ -18,7 +18,9 @@ export async function POST(req: Request) { const sinir = hizSiniriAsimi(req, "kayit"); 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 isAdmin = isConfiguredAdminEmail(email); const plan = await prisma.plan.findUnique({ where: { code: isAdmin ? "PREMIUM" : "FREE" } }); diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..76e8de1 --- /dev/null +++ b/src/app/api/billing/checkout/route.ts @@ -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 }); + } +} diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts new file mode 100644 index 0000000..6acdcd1 --- /dev/null +++ b/src/app/api/billing/webhook/route.ts @@ -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 }); +} diff --git a/src/app/api/cron/daily-intelligence/route.ts b/src/app/api/cron/daily-intelligence/route.ts new file mode 100644 index 0000000..11ce2fe --- /dev/null +++ b/src/app/api/cron/daily-intelligence/route.ts @@ -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 }); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..4d755f9 --- /dev/null +++ b/src/app/api/health/route.ts @@ -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" } }); + } +} diff --git a/src/app/api/magic-ai/analyze/route.ts b/src/app/api/magic-ai/analyze/route.ts new file mode 100644 index 0000000..97687da --- /dev/null +++ b/src/app/api/magic-ai/analyze/route.ts @@ -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 }); + } +} diff --git a/src/app/api/tiktok-shop/import/route.ts b/src/app/api/tiktok-shop/import/route.ts new file mode 100644 index 0000000..68ee5a8 --- /dev/null +++ b/src/app/api/tiktok-shop/import/route.ts @@ -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 }); + } +} diff --git a/src/app/api/tracked-stores/route.ts b/src/app/api/tracked-stores/route.ts index 5b39a7a..b65cc88 100644 --- a/src/app/api/tracked-stores/route.ts +++ b/src/app/api/tracked-stores/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { currentUser } from "@/lib/auth/current-user"; 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() { const user = await currentUser(); @@ -11,14 +15,18 @@ export async function GET() { } export async function POST(req: Request) { + const rateLimit = hizSiniriAsimi(req, "arama"); + if (rateLimit) return rateLimit; const user = await currentUser(); 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 limit = getFeatureLimit(plan as any, "tracked_stores"); const count = await prisma.trackedStore.count({ where: { userId: user.id } }); if (limit !== null && count >= limit) return NextResponse.json({ error: "TRACKED_STORE_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 }); - const body = await req.json(); - const domain = String(body.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase(); + let domain = parsed.data.domain.replace(/^https?:\/\//i, "").split("/")[0]!.toLowerCase().replace(/^www\./, ""); + try { domain = new URL(`https://${domain}`).hostname; } catch { return NextResponse.json({ error: "INVALID_DOMAIN" }, { status: 400 }); } const store = await prisma.store.findUnique({ where: { domain } }); if (!store) return NextResponse.json({ error: "STORE_NOT_FOUND" }, { status: 404 }); const row = await prisma.trackedStore.upsert({ where: { userId_storeId: { userId: user.id, storeId: store.id } }, update: {}, create: { userId: user.id, storeId: store.id } }); diff --git a/src/app/dashboard/ads/page.tsx b/src/app/dashboard/ads/page.tsx index 97fe278..ab793b4 100644 --- a/src/app/dashboard/ads/page.tsx +++ b/src/app/dashboard/ads/page.tsx @@ -21,6 +21,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< 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 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)); 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) => {x})}
- {q && masked.length === 0 && isAdmin && } - {q && masked.length === 0 && !isAdmin && ( -
-

“{q}” için sonuç bulunamadı

-

Yeni reklamların içe aktarılması için yöneticinizle iletişime geçin.

-
- )} + {q && masked.length === 0 && } {masked.map((ad: any) => ( {ad.isLocked &&
Start now — Unlock winners
} diff --git a/src/app/dashboard/brand-tracker/page.tsx b/src/app/dashboard/brand-tracker/page.tsx index 25a9365..10a5544 100644 --- a/src/app/dashboard/brand-tracker/page.tsx +++ b/src/app/dashboard/brand-tracker/page.tsx @@ -15,7 +15,7 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams: { niche: { contains: query, mode: "insensitive" } } ] } : {}; - const [brands, tracked] = await Promise.all([ + const [brands, tracked, alerts] = await Promise.all([ prisma.brandPage.findMany({ where, include: { @@ -25,7 +25,8 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams: orderBy: { ads: { _count: "desc" } }, 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 limit = getFeatureLimit(planFromUser(user as any), "followed_brands"); @@ -40,6 +41,7 @@ export default async function BrandTrackerPage({ searchParams }: { searchParams: + {alerts.length > 0 &&

Yeni reklam uyarıları

{alerts.map((alert) =>
{alert.trackedBrand.brandPage.name}

{alert.title}

{alert.createdAt.toLocaleDateString("tr-TR")}
)}
}
{brands.map((brand) => { const activeAds = brand.ads.filter((ad) => ad.status === "ACTIVE").length; diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 05580c0..12d1004 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { requireUser } from "@/lib/auth/current-user"; +import { DashboardMobileNav } from "@/components/dashboard-mobile-nav"; const nav = [ ["Ads", "/dashboard/ads"], @@ -20,6 +21,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
WinningHunter.AI +
- {user.role === "ADMIN" ? Admin : Upgrade} + {user.role !== "ADMIN" && Upgrade}
{user.name || user.email}
{user.subscription?.plan.name}
diff --git a/src/app/dashboard/magic-ai/page.tsx b/src/app/dashboard/magic-ai/page.tsx index af0f547..b15ad3f 100644 --- a/src/app/dashboard/magic-ai/page.tsx +++ b/src/app/dashboard/magic-ai/page.tsx @@ -3,6 +3,7 @@ import { prisma } from "@/lib/db"; import { requireUser } from "@/lib/auth/current-user"; import { Card } from "@/components/ui/card"; import { AdCreativeMedia } from "@/components/ad-creative-media"; +import { MagicAiRunner } from "@/components/magic-ai-runner"; function sentence(value?: string | null) { 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> }) { - await requireUser(); + const user = await requireUser(); 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 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 (

Magic AI

Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.

-
+ + {analyses.length > 0 &&
{analyses.map((analysis) =>

{analysis.query || "Genel kreatif analizi"}

{analysis.model}

{analysis.summary}

{(analysis.insights as Array<{title:string;action:string;confidence:number}>).map((item, index) =>
{item.title}

{item.action}

)}
)}
}
Creative Intelligence

{ads.length} reklamdan aksiyon planı

Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.

{ads.map((ad) => { const insight = recommendation(ad); return

{ad.headline || "Başlıksız reklam"}

{ad.brandPage?.name || "Bilinmeyen marka"} · {ad.mediaType} · {ad.daysRunning || "—"} gün

{insight.label}
Kanca

{sentence(ad.primaryText)}

{insight.text}

diff --git a/src/app/dashboard/store-tracker/page.tsx b/src/app/dashboard/store-tracker/page.tsx index 3353459..2f98d07 100644 --- a/src/app/dashboard/store-tracker/page.tsx +++ b/src/app/dashboard/store-tracker/page.tsx @@ -1,23 +1,19 @@ import { prisma } from "@/lib/db"; import { requireUser } from "@/lib/auth/current-user"; import { Card } from "@/components/ui/card"; +import { StoreTrackerManager } from "@/components/store-tracker-manager"; +import { getFeatureLimit, planFromUser } from "@/lib/plans"; export default async function StoreTrackerPage() { const user = await requireUser(); const tracked = await prisma.trackedStore.findMany({ where: { userId: user.id }, include: { store: { include: { products: { where: { isBestSeller: true }, take: 2 } } } }, orderBy: { createdAt: "desc" } }); + const limit = getFeatureLimit(planFromUser(user as any), "tracked_stores"); return (

Store Tracker

Plan limitine göre rakip Shopify mağazalarını watchlist’e ekle.

-
- - -
-
- {tracked.map((t) =>
{t.store.name}
{t.store.domain} · {t.store.products.map((p) => p.title).join(", ")} · €{t.store.estRevenue30dMax?.toLocaleString()} est.
)} - {!tracked.length &&
Free planda tracker kapalıdır. Admin demo hesabında örnek takip bulunur.
} -
+
); diff --git a/src/app/dashboard/tiktok-shop/page.tsx b/src/app/dashboard/tiktok-shop/page.tsx index 8305dc8..d3d7829 100644 --- a/src/app/dashboard/tiktok-shop/page.tsx +++ b/src/app/dashboard/tiktok-shop/page.tsx @@ -1,42 +1,46 @@ import { Prisma } from "@prisma/client"; import { prisma } from "@/lib/db"; import { requireUser } from "@/lib/auth/current-user"; +import { planFromUser } from "@/lib/plans"; 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 } }) { - const growth = Math.max(0, product.store.monthlyVisitGrowth || 0); - const traffic = Math.min(25, Math.log10(Math.max(1, product.store.monthlyVisits || 1)) * 4); +function opportunityScore(product: { soldCount: number | null; rating: number | null; reviewCount: number | null; price: number | null }) { + const sales = Math.min(45, Math.log10(Math.max(1, product.soldCount || 1)) * 10); + 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; - 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> }) { - 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 q = resolved.q?.trim(); - const country = resolved.country?.trim(); - const where: Prisma.StoreProductWhereInput = { - store: { pixels: { some: { type: { contains: "TikTok", mode: "insensitive" } } }, ...(country ? { country } : {}) }, - ...(q ? { OR: [{ title: { contains: q, mode: "insensitive" } }, { store: { name: { contains: q, mode: "insensitive" } } }] } : {}) + const region = resolved.region?.trim(); + const where: Prisma.TikTokProductWhereInput = { + ...(region ? { region } : {}), + ...(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 countries = [...new Set(products.map((product) => product.store.country).filter(Boolean))] as string[]; - - return ( -
-

TikTok Shop

TikTok Pixel bulunan mağazalardaki ürünleri büyüme, trafik, fiyat ve bestseller sinyalleriyle sırala.

-
- - - -
-
Ürün sinyali
{ranked.length}
TikTok Pixel mağazası
{new Set(ranked.map((item) => item.storeId)).size}
Ortalama fırsat skoru
{ranked.length ? Math.round(ranked.reduce((sum, item) => sum + item.score, 0) / ranked.length) : 0}
-
{ranked.map((product) => - {product.imageUrl ? :
Ürün görseli yok
} -

{product.title}

{product.store.name} · {product.store.country}

{product.score}
-
{product.price ? `${product.price} ${product.currency || product.store.currency || ""}` : "—"}
Fiyat
{product.store.monthlyVisitGrowth || 0}%
Büyüme
{product.isBestSeller ? "Evet" : "Hayır"}
Bestseller
-
)}{!ranked.length && TikTok Pixel sinyalli ürün bulunamadı.}
-
- ); + const regions = [...new Set(products.map((product) => product.region).filter(Boolean))] as string[]; + return
+

TikTok Shop

Apify üzerinden canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.

+ +
+ + + +
+
Canlı ürün
{ranked.length}
Mağaza
{new Set(ranked.map((item) => item.shopExternalId || item.shopName)).size}
Toplam satış sinyali
{ranked.reduce((sum, item) => sum + (item.soldCount || 0), 0).toLocaleString("tr-TR")}
+
{ranked.map((product) => + {product.imageUrl ? :
Ürün görseli yok
} +

{product.title}

{product.shopName || "Mağaza bilinmiyor"} · {product.region}

{product.score}
+
{product.price ? `${product.price} ${product.currency || ""}` : "—"}
Fiyat
{(product.soldCount || 0).toLocaleString("tr-TR")}
Satış
{product.rating || "—"}
Puan
+ {product.productUrl && TikTok’ta aç →} +
)}{!ranked.length && Yukarıdan bir ürün aratıp canlı veriyi getirin.}
+
; } diff --git a/src/app/dashboard/trends/page.tsx b/src/app/dashboard/trends/page.tsx index 4311f67..6784fcd 100644 --- a/src/app/dashboard/trends/page.tsx +++ b/src/app/dashboard/trends/page.tsx @@ -14,9 +14,10 @@ function Bars({ rows, total }: { rows: [string, number][]; total: number }) { export default async function TrendsPage() { 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.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 longRunners = ads.filter((ad) => (ad.daysRunning || 0) >= 30); @@ -31,6 +32,7 @@ export default async function TrendsPage() {
İzlenen reklam
{ads.length}
Aktif reklam
{active.length}
30+ gün yaşayan
{longRunners.length}
Son 14 gün
{newAds.length}

Niche dağılımı

Medya formatı

Ülke sinyali

sum + ad.countries.length, 0)} />

Hızlı büyüyen mağazalar

Aylık ziyaret büyümesine göre

{stores.slice(0, 8).map((store) =>
{store.name}
{store.niche || "Niche yok"} · {store.country || "—"}
+{store.monthlyVisitGrowth || 0}%
)}
+

14 günlük geçmiş

Günlük otomasyonla kaydedilen reklam ve TikTok Shop sinyalleri

{history.map((snapshot) => { const metrics = snapshot.metrics as any; return
{snapshot.date.toLocaleDateString("tr-TR")}

{metrics.ads?.active || 0} aktif reklam

{metrics.tiktok?.total || 0} TikTok ürün

; })}{!history.length &&

İlk günlük snapshot henüz oluşmadı.

}
); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 533bcf8..211af80 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -27,24 +27,7 @@ export default function LoginPage() { body: JSON.stringify({ email, password }) }); 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)."); - 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."); + setError("E-posta veya şifre hatalı."); return; } location.href = "/dashboard/ads"; @@ -54,7 +37,7 @@ export default function LoginPage() {

Giriş yap

-

Kendi oluşturduğunuz hesapla veya hazır demo hesapla giriş yapabilirsiniz.

+

Hesabınızla veya Google üzerinden güvenli giriş yapın.

- -
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 0e498b6..6b026ee 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -1,6 +1,7 @@ import { prisma } from "@/lib/db"; import { LinkButton } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { CheckoutButton } from "@/components/checkout-button"; export default async function PricingPage() { const plans = await prisma.plan.findMany({ orderBy: { sortOrder: "asc" } }).catch(() => []); @@ -29,7 +30,7 @@ export default async function PricingPage() {
  • Store tracker: {String((plan.features as any).trackedStores ?? "Sınırsız")}
  • Saved ads: {String((plan.features as any).savedAds ?? "Sınırsız")}
  • - Başla + {plan.code === "FREE" ? Ücretsiz başla : } ))}
    diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index a2545b4..548ef09 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -57,7 +57,7 @@ export default function RegisterPage() { setPassword(e.target.value)} diff --git a/src/components/ads/apify-empty-search.tsx b/src/components/ads/apify-empty-search.tsx index 200c896..bd67e6e 100644 --- a/src/components/ads/apify-empty-search.tsx +++ b/src/components/ads/apify-empty-search.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; const RESULT_LIMIT = 10; 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 [maxResults, setMaxResults] = useState(RESULT_LIMIT); const [busy, setBusy] = useState(false); @@ -17,18 +17,12 @@ export function ApifyEmptySearch({ query }: { query: string }) { setBusy(true); setMessage(""); try { - const response = await fetch("/api/admin/ingest/apify", { + const response = await fetch("/api/ads/import-apify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - searchTerms: [query], - country: "ALL", - adActiveStatus: "ACTIVE", - mediaType: "ALL", + searchTerm: query, maxResults, - maxCostUsd: Math.max(0.1, Math.ceil(maxResults * 0.004 * 10) / 10), - scrapeAdDetails: true, - includeAboutPage: false }) }); const data = await response.json().catch(() => ({})); @@ -60,18 +54,18 @@ export function ApifyEmptySearch({ query }: { query: string }) { 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" > - {RESULT_OPTIONS.map((count) => )} + {RESULT_OPTIONS.filter((count) => count <= planLimit).map((count) => )}
    -

    Apify · Yalnızca yöneticiler

    +

    Apify · Plan limiti: {planLimit || "erişim yok"} reklam · Kullanılan kayıt kadar API kredisi

    {message &&

    {message}

    }
    ); diff --git a/src/components/checkout-button.tsx b/src/components/checkout-button.tsx new file mode 100644 index 0000000..b55995a --- /dev/null +++ b/src/components/checkout-button.tsx @@ -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 <>{error &&

    {error}

    }; +} diff --git a/src/components/dashboard-mobile-nav.tsx b/src/components/dashboard-mobile-nav.tsx new file mode 100644 index 0000000..bbe282f --- /dev/null +++ b/src/components/dashboard-mobile-nav.tsx @@ -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
    {open && }
    ; +} diff --git a/src/components/magic-ai-runner.tsx b/src/components/magic-ai-runner.tsx new file mode 100644 index 0000000..87a83d2 --- /dev/null +++ b/src/components/magic-ai-runner.tsx @@ -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
    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" />{message &&

    {message}

    }
    ; +} diff --git a/src/components/store-tracker-manager.tsx b/src/components/store-tracker-manager.tsx new file mode 100644 index 0000000..27b0e69 --- /dev/null +++ b/src/components/store-tracker-manager.tsx @@ -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(null); + const [message, setMessage] = useState(""); + const [error, setError] = useState(false); + + async function addStore(event: FormEvent) { + 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 ( +
    + + 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" /> + + +
    Yalnızca WinningHunter veritabanında bulunan domainler eklenebilir.{tracked.length} / {limit === null ? "∞" : limit}
    + {message &&
    {message}
    } +
    + {tracked.map((item) =>
    {item.store.name}
    {item.store.domain} · {item.store.products.map((product) => product.title).join(", ") || "Bestseller yok"} · €{item.store.estRevenue30dMax?.toLocaleString() || "—"} est.
    )} + {!tracked.length &&
    Henüz takip edilen mağaza yok.
    } +
    +
    + ); +} diff --git a/src/components/tiktok-shop-import.tsx b/src/components/tiktok-shop-import.tsx new file mode 100644 index 0000000..f771175 --- /dev/null +++ b/src/components/tiktok-shop-import.tsx @@ -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
    + setQuery(e.target.value)} placeholder="TikTok Shop ürününü canlı ara" className="rounded-2xl border border-slate-200 px-4 py-3" /> + + + + {message &&

    {message}

    } +
    ; +} diff --git a/src/lib/apify-tiktok.ts b/src/lib/apify-tiktok.ts new file mode 100644 index 0000000..0c7d000 --- /dev/null +++ b/src/lib/apify-tiktok.ts @@ -0,0 +1,103 @@ +import { Prisma } from "@prisma/client"; +import { prisma } from "@/lib/db"; + +type Row = Record; +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 }; +} diff --git a/src/lib/openai-analysis.ts b/src/lib/openai-analysis.ts new file mode 100644 index 0000000..fe4fa0c --- /dev/null +++ b/src/lib/openai-analysis.ts @@ -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 { + 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); } +} diff --git a/src/lib/quota.ts b/src/lib/quota.ts index ad641ec..9d40bed 100644 --- a/src/lib/quota.ts +++ b/src/lib/quota.ts @@ -102,11 +102,25 @@ export async function checkAndConsumeQuota(params: { }; } - const updated = await prisma.usageCounter.update({ - where: { id: counter.id }, + // Koşullu artış, eşzamanlı iki isteğin aynı kalan kotayı tüketmesini önler. + const claimed = await prisma.usageCounter.updateMany({ + where: { id: counter.id, used: { lte: limit - amount } }, 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 { allowed: true, used: updated.used, @@ -115,3 +129,18 @@ export async function checkAndConsumeQuota(params: { 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) } + }); +} diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts new file mode 100644 index 0000000..b266410 --- /dev/null +++ b/src/lib/stripe.ts @@ -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; +} diff --git a/src/lib/trend-snapshots.ts b/src/lib/trend-snapshots.ts new file mode 100644 index 0000000..24f0a0a --- /dev/null +++ b/src/lib/trend-snapshots.ts @@ -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()).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: `

    ${brand} için yeni reklam bulundu.

    ${title}

    WinningHunter'da görüntüle

    ` }) }); + 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; +} diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..d051b16 --- /dev/null +++ b/tests/core.test.ts @@ -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; +});