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