This commit is contained in:
cureb 2026-01-29 00:45:52 +00:00
parent e0f3f5d306
commit 3a678fec20
32 changed files with 1893 additions and 539 deletions

View File

@ -6,7 +6,15 @@ function mapCommentToDealCommentResponse(comment) {
id: comment.id,
text: comment.text, // eğer DB'de content ise burada text'e çevir
createdAt: formatDateAsString(comment.createdAt),
parentId:comment.parentId,
parentId: comment.parentId ?? null,
likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
repliesCount: Number.isFinite(comment.repliesCount)
? comment.repliesCount
: comment._count?.replies ?? 0,
hasReplies: Number.isFinite(comment.repliesCount)
? comment.repliesCount > 0
: (comment._count?.replies ?? 0) > 0,
myLike: Boolean(comment.myLike),
user: {
id: comment.user.id,
username: comment.user.username,

View File

@ -6,6 +6,8 @@ function mapDealToDealCardResponse(deal) {
title: deal.title,
description: deal.description || "",
price: deal.price ?? null,
originalPrice: deal.originalPrice ?? null,
shippingPrice: deal.shippingPrice ?? null,
score: deal.score,
commentsCount: deal.commentCount,

View File

@ -55,6 +55,8 @@ function mapDealToDealDetailResponse(deal) {
description: deal.description || "",
url: deal.url ?? null,
price: deal.price ?? null,
originalPrice: deal.originalPrice ?? null,
shippingPrice: deal.shippingPrice ?? null,
score: Number.isFinite(deal.score) ? deal.score : 0,
commentsCount: deal._count?.comments ?? 0,
@ -71,6 +73,10 @@ function mapDealToDealDetailResponse(deal) {
username: deal.user.username,
avatarUrl: deal.user.avatarUrl ?? null,
},
userStats: {
totalLikes: deal.userStats?.totalLikes ?? 0,
totalDeals: deal.userStats?.totalDeals ?? 0,
},
// ✅ FIX: SellerSummarySchema genelde id ister -> custom seller için -1
seller: deal.seller
@ -95,12 +101,21 @@ function mapDealToDealDetailResponse(deal) {
if (!comment.user)
throw new Error("comment.user is missing (include comments.user in query)")
return {
id: comment.id,
text: comment.text,
createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"),
user: {
id: comment.user.id,
return {
id: comment.id,
text: comment.text,
parentId: comment.parentId ?? null,
likeCount: Number.isFinite(comment.likeCount) ? comment.likeCount : 0,
repliesCount: Number.isFinite(comment.repliesCount)
? comment.repliesCount
: comment._count?.replies ?? 0,
hasReplies: Number.isFinite(comment.repliesCount)
? comment.repliesCount > 0
: (comment._count?.replies ?? 0) > 0,
myLike: Boolean(comment.myLike),
createdAt: requiredIsoString(comment.createdAt, "comment.createdAt"),
user: {
id: comment.user.id,
username: comment.user.username,
avatarUrl: comment.user.avatarUrl ?? null,
},

View File

@ -9,6 +9,7 @@ function mapMeResultToResponse(user) {
username: user.username,
email: user.email,
avatarUrl: user.avatarUrl ?? null,
role: user.role,
};
}

View File

@ -0,0 +1,14 @@
function mapSellerToSellerDetailsResponse(seller) {
if (!seller) return null
return {
id: seller.id,
name: seller.name,
url: seller.url || null,
logoUrl: seller.sellerLogo || null,
}
}
module.exports = {
mapSellerToSellerDetailsResponse,
}

View File

@ -1,63 +1,90 @@
const prisma = require("./client"); // Prisma client
const prisma = require("./client") // Prisma client
/**
* Kategoriyi slug'a göre bul
* Kategoriyi slug'a gore bul
*/
async function findCategoryBySlug(slug, options = {}) {
const s = String(slug ?? "").trim().toLowerCase();
const s = String(slug ?? "").trim().toLowerCase()
return prisma.category.findUnique({
where: { slug: s },
select: options.select || undefined,
include: options.include || undefined,
});
})
}
/**
* Kategorinin fırsatlarını al
* Sayfalama ve filtreler ile fırsatları çekiyoruz
* Kategorinin firsatlarini al
* Sayfalama ve filtreler ile firsatlari cekiyoruz
*/
async function listCategoryDeals({ where = {}, skip = 0, take = 10 }) {
return prisma.deal.findMany({
where,
skip,
take,
orderBy: { createdAt: "desc" }, // Yeni fırsatlar önce gelsin
});
orderBy: { createdAt: "desc" },
})
}
async function getCategoryDescendantIds(categoryId) {
const rootId = Number(categoryId)
if (!Number.isInteger(rootId) || rootId <= 0) {
throw new Error("categoryId must be int")
}
const seen = new Set([rootId])
let queue = [rootId]
while (queue.length > 0) {
const children = await prisma.category.findMany({
where: { parentId: { in: queue } },
select: { id: true },
})
const next = []
for (const child of children) {
if (!seen.has(child.id)) {
seen.add(child.id)
next.push(child.id)
}
}
queue = next
}
return Array.from(seen)
}
async function getCategoryBreadcrumb(categoryId, { includeUndefined = false } = {}) {
let currentId = Number(categoryId);
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int");
let currentId = Number(categoryId)
if (!Number.isInteger(currentId)) throw new Error("categoryId must be int")
const path = [];
const visited = new Set();
const path = []
const visited = new Set()
// Bu döngü, root kategoriye kadar gidip breadcrumb oluşturacak
while (true) {
if (visited.has(currentId)) break;
visited.add(currentId);
if (visited.has(currentId)) break
visited.add(currentId)
const cat = await prisma.category.findUnique({
where: { id: currentId },
select: { id: true, name: true, slug: true, parentId: true }, // Yalnızca gerekli alanları seçiyoruz
});
select: { id: true, name: true, slug: true, parentId: true },
})
if (!cat) break;
if (!cat) break
// Undefined'ı istersen breadcrumb'ta göstermiyoruz
if (includeUndefined || cat.id !== 0) {
path.push({ id: cat.id, name: cat.name, slug: cat.slug });
path.push({ id: cat.id, name: cat.name, slug: cat.slug })
}
if (cat.parentId === null || cat.parentId === undefined) break;
currentId = cat.parentId; // Bir üst kategoriye geçiyoruz
if (cat.parentId === null || cat.parentId === undefined) break
currentId = cat.parentId
}
return path.reverse(); // Kökten başlayarak, kategoriyi en son eklediğimiz için tersine çeviriyoruz
return path.reverse()
}
module.exports = {
getCategoryBreadcrumb,
findCategoryBySlug,
listCategoryDeals,
};
getCategoryDescendantIds,
}

View File

@ -4,17 +4,27 @@ function getDb(db) {
return db || prisma
}
function withDeletedFilter(where = {}, options = {}) {
if (options.includeDeleted || Object.prototype.hasOwnProperty.call(where, "deletedAt")) {
return where
}
return { AND: [where, { deletedAt: null }] }
}
async function findComments(where, options = {}) {
return prisma.comment.findMany({
where,
where: withDeletedFilter(where, options),
include: options.include || undefined,
select: options.select || undefined,
orderBy: options.orderBy || { createdAt: "desc" },
skip: Number.isInteger(options.skip) ? options.skip : undefined,
take: Number.isInteger(options.take) ? options.take : undefined,
})
}
async function findComment(where, options = {}) {
return prisma.comment.findFirst({
where,
where: withDeletedFilter(where, options),
include: options.include || undefined,
select: options.select || undefined,
orderBy: options.orderBy || { createdAt: "desc" },
@ -29,12 +39,18 @@ async function createComment(data, options = {}, db) {
})
}
async function deleteComment(where) {
return prisma.comment.delete({ where })
}
async function countComments(where = {}, db) {
async function deleteComment(where, db) {
const p = getDb(db)
return p.comment.count({ where })
return p.comment.delete({ where })
}
async function softDeleteComment(where, db) {
const p = getDb(db)
return p.comment.updateMany({ where, data: { deletedAt: new Date() } })
}
async function countComments(where = {}, db, options = {}) {
const p = getDb(db)
return p.comment.count({ where: withDeletedFilter(where, options) })
}
@ -43,5 +59,6 @@ module.exports = {
countComments,
createComment,
deleteComment,
softDeleteComment,
findComment
}

61
db/commentLike.db.js Normal file
View File

@ -0,0 +1,61 @@
const prisma = require("./client")
async function findLike(commentId, userId, db) {
const p = db || prisma
return p.commentLike.findUnique({
where: { commentId_userId: { commentId, userId } },
})
}
async function findLikesByUserAndCommentIds(userId, commentIds, db) {
const p = db || prisma
return p.commentLike.findMany({
where: { userId, commentId: { in: commentIds } },
select: { commentId: true },
})
}
async function setCommentLike({ commentId, userId, like }) {
return prisma.$transaction(async (tx) => {
const comment = await tx.comment.findUnique({
where: { id: commentId },
select: { id: true, likeCount: true },
})
if (!comment) throw new Error("Yorum bulunamadı.")
const existing = await findLike(commentId, userId, tx)
if (like) {
if (existing) {
return { liked: true, delta: 0, likeCount: comment.likeCount }
}
await tx.commentLike.create({
data: { commentId, userId },
})
const updated = await tx.comment.update({
where: { id: commentId },
data: { likeCount: { increment: 1 } },
select: { likeCount: true },
})
return { liked: true, delta: 1, likeCount: updated.likeCount }
}
if (!existing) {
return { liked: false, delta: 0, likeCount: comment.likeCount }
}
await tx.commentLike.delete({
where: { commentId_userId: { commentId, userId } },
})
const updated = await tx.comment.update({
where: { id: commentId },
data: { likeCount: { decrement: 1 } },
select: { likeCount: true },
})
return { liked: false, delta: -1, likeCount: updated.likeCount }
})
}
module.exports = {
findLikesByUserAndCommentIds,
setCommentLike,
}

View File

@ -4,63 +4,9 @@ function getDb(db) {
return db || prisma
}
const DEAL_CARD_INCLUDE = {
user: { select: { id: true, username: true, avatarUrl: true } },
seller: { select: { id: true, name: true, url: true } },
images: {
orderBy: { order: "asc" },
take: 1,
select: { imageUrl: true },
},
}
async function getDealCards({ where = {}, skip = 0, take = 10, orderBy = [{ createdAt: "desc" }] }) {
try {
// Prisma sorgusunu çalıştırıyoruz ve genelleştirilmiş formatta alıyoruz
return await prisma.deal.findMany({
where,
skip,
take,
orderBy,
include: DEAL_CARD_INCLUDE, // Her zaman bu alanları dahil ediyoruz
})
} catch (err) {
throw new Error(`Fırsatlar alınırken bir hata oluştu: ${err.message}`)
}
}
/**
* Sayfalama ve diğer parametrelerle deal'leri çeker
*
* @param {object} params - where, skip, take, orderBy parametreleri
* @returns {object} - Deal card'leri ve toplam sayıyı döner
*/
async function getPaginatedDealCards({ where = {}, page = 1, limit = 10, orderBy = [{ createdAt: "desc" }] }) {
const pagination = clampPagination({ page, limit })
// Deal card verilerini ve toplam sayıyı alıyoruz
const [deals, total] = await Promise.all([
getDealCards({
where,
skip: pagination.skip,
take: pagination.limit,
orderBy,
}),
countDeals(where), // Total count almak için
])
return {
page: pagination.page,
total,
totalPages: Math.ceil(total / pagination.limit),
results: deals, // Burada raw data döndürülüyor, map'lemiyoruz
}
}
async function findDeals(where = {}, options = {}) {
return prisma.deal.findMany({
async function findDeals(where = {}, options = {}, db) {
const p = getDb(db)
return p.deal.findMany({
where,
include: options.include || undefined,
select: options.select || undefined,
@ -70,39 +16,16 @@ async function findDeals(where = {}, options = {}) {
})
}
async function findSimilarCandidatesByCategory(categoryId, excludeDealId, { take = 80 } = {}) {
const safeTake = Math.min(Math.max(Number(take) || 80, 1), 200)
async function findSimilarCandidates(where, options = {}, db) {
const p = getDb(db)
const safeTake = Math.min(Math.max(Number(options.take) || 30, 1), 200)
return prisma.deal.findMany({
where: {
id: { not: Number(excludeDealId) },
status: "ACTIVE",
categoryId: Number(categoryId),
},
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
return p.deal.findMany({
where,
orderBy: options.orderBy || [{ score: "desc" }, { createdAt: "desc" }],
take: safeTake,
include: {
seller: { select: { id: true, name: true, url: true } },
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
},
})
}
async function findSimilarCandidatesBySeller(sellerId, excludeDealId, { take = 30 } = {}) {
const safeTake = Math.min(Math.max(Number(take) || 30, 1), 200)
return prisma.deal.findMany({
where: {
id: { not: Number(excludeDealId) },
status: "ACTIVE",
sellerId: Number(sellerId),
},
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
take: safeTake,
include: {
seller: { select: { id: true, name: true, url: true } },
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
},
include: options.include || undefined,
select: options.select || undefined,
})
}
@ -115,9 +38,9 @@ async function findDeal(where, options = {}, db) {
})
}
async function createDeal(data, options = {}) {
return prisma.deal.create({
async function createDeal(data, options = {}, db) {
const p = getDb(db)
return p.deal.create({
data,
include: options.include || undefined,
select: options.select || undefined,
@ -130,32 +53,36 @@ async function updateDeal(where, data, options = {}, db) {
where,
data,
include: options.include || undefined,
select: options.select || undefined,
select: options.select || undefined,
})
}
async function countDeals(where = {}) {
return prisma.deal.count({ where })
async function countDeals(where = {}, db) {
const p = getDb(db)
return p.deal.count({ where })
}
async function findVotes(where = {}, options = {}) {
return prisma.dealVote.findMany({
async function findVotes(where = {}, options = {}, db) {
const p = getDb(db)
return p.dealVote.findMany({
where,
include: options.include || undefined,
select: options.select || undefined,
})
}
async function createVote(data, options = {}) {
return prisma.dealVote.create({
async function createVote(data, options = {}, db) {
const p = getDb(db)
return p.dealVote.create({
data,
include: options.include || undefined,
select: options.select || undefined,
})
}
async function updateVote(where, data, options = {}) {
return prisma.dealVote.update({
async function updateVote(where, data, options = {}, db) {
const p = getDb(db)
return p.dealVote.update({
where,
data,
include: options.include || undefined,
@ -163,14 +90,9 @@ async function updateVote(where, data, options = {}) {
})
}
async function countVotes(where = {}) {
return prisma.dealVote.count({ where })
}
async function getDealWithImages(dealId) {
return prisma.deal.findUnique({
where: { id: dealId },
include: { images: { orderBy: { order: "asc" } } },
});
async function countVotes(where = {}, db) {
const p = getDb(db)
return p.dealVote.count({ where })
}
async function aggregateDeals(where = {}, db) {
@ -182,12 +104,9 @@ async function aggregateDeals(where = {}, db) {
})
}
module.exports = {
findDeals,
aggregateDeals,
getDealWithImages,
findSimilarCandidates,
findDeal,
createDeal,
updateDeal,
@ -196,8 +115,5 @@ module.exports = {
createVote,
updateVote,
countVotes,
findSimilarCandidatesByCategory,
findSimilarCandidatesBySeller,
getDealCards,
getPaginatedDealCards
aggregateDeals,
}

318
docs/frontend-api.md Normal file
View File

@ -0,0 +1,318 @@
# Frontend API Guide (HotTRDeals)
This file is a frontend-focused summary of current backend routes, auth, and response models.
Server entry: `server.js`.
## Base URL and CORS
- Local API base: `http://localhost:3000`
- All routes below are relative to `/api`.
- CORS in dev allows `http://localhost:5173` and `credentials: true`.
## Auth summary
- Access token is returned in response body: `{ token, user }`.
- Send access token via header: `Authorization: Bearer <token>`.
- Refresh token is stored in httpOnly cookie named `rt`.
- For `/auth/refresh` and `/auth/logout`, the frontend must send cookies (`withCredentials: true`).
- Cookie options:
- Dev: `secure=false`, `sameSite=lax`
- Prod: `secure=true`, `sameSite=none`
## Roles
- Roles: `USER`, `MOD`, `ADMIN`.
- `requireRole("MOD")` means only MOD and ADMIN can access.
## Common response shapes
- Errors are inconsistent:
- Some endpoints return `{ error: "..." }`
- Some endpoints return `{ message: "..." }`
- Expect both in frontend error handling.
## Common models (from adapters/contracts)
### PublicUserSummary
```
{
id: number,
username: string,
avatarUrl: string | null
}
```
### PublicUserDetails
```
{
id: number,
username: string,
avatarUrl: string | null,
email: string,
createdAt: string (ISO)
}
```
### AuthUser
```
{
id: number,
username: string,
email: string,
role: "USER" | "MOD" | "ADMIN",
avatarUrl: string | null
}
```
### DealCard
```
{
id: number,
title: string,
description: string,
price: number | null,
originalPrice?: number | null,
shippingPrice?: number | null,
score: number,
commentsCount: number,
url: string | null,
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED",
saleType: "ONLINE" | "OFFLINE" | "CODE",
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE",
myVote: -1 | 0 | 1,
createdAt: string (ISO),
updatedAt: string (ISO),
user: PublicUserSummary,
seller: { name: string, url: string | null },
imageUrl: string
}
```
### DealDetail
```
{
id: number,
title: string,
description: string,
url: string | null,
price: number | null,
originalPrice?: number | null,
shippingPrice?: number | null,
score: number,
commentsCount: number,
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED",
saleType: "ONLINE" | "OFFLINE" | "CODE",
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE",
createdAt: string (ISO),
updatedAt: string (ISO),
user: PublicUserSummary,
seller: { id: number, name: string, url: string | null },
images: [{ id: number, imageUrl: string, order: number }],
comments: [{ id: number, text: string, createdAt: string, parentId?: number | null, user: PublicUserSummary }],
breadcrumb: [{ id: number, name: string, slug: string }],
notice: {
id: number,
dealId: number,
title: string,
body: string | null,
severity: "INFO" | "WARNING" | "DANGER" | "SUCCESS",
isActive: boolean,
createdBy: number,
createdAt: string,
updatedAt: string
} | null,
similarDeals: [{ id: number, title: string, price: number | null, score: number, imageUrl: string, sellerName: string, createdAt: string | null }]
}
```
### DealListResponse
```
{
page: number,
total: number,
totalPages: number,
results: DealCard[]
}
```
### Comment (DealComment)
```
{
id: number,
text: string,
createdAt: string,
parentId?: number | null,
user: PublicUserSummary
}
```
### UserProfile
```
{
user: PublicUserDetails,
stats: { totalLikes: number, totalShares: number, totalComments: number, totalDeals: number },
deals: DealCard[],
comments: [{ ...Comment, deal: { id: number, title: string } }]
}
```
### VoteResponse
```
{
dealId: number,
voteType: -1 | 0 | 1,
delta: number,
score: number | null
}
```
### VoteListResponse
```
{
votes: [{ id, dealId, userId, voteType, createdAt, lastVotedAt }]
}
```
## Endpoints
### Auth (`/api/auth`)
- `POST /register`
- Body: `{ username, email, password }`
- Response: `{ token, user: AuthUser }` and sets `rt` cookie if refresh token exists.
- `POST /login`
- Body: `{ email, password }`
- Response: `{ token, user: AuthUser }` and sets `rt` cookie.
- `POST /refresh`
- Cookie required: `rt`
- Response: `{ token, user: AuthUser }` and rotates `rt` cookie.
- `POST /logout`
- Cookie optional: `rt`
- Response: `204 No Content`, clears `rt` cookie.
- `GET /me`
- Auth: required
- Response: `AuthUser`
- Note: current implementation reads `req.user.userId` in adapter, while auth middleware sets `req.auth`. If `req.user` is undefined, this endpoint can fail.
### Account (`/api/account`)
- `POST /avatar`
- Auth: required
- Multipart: `file` (single)
- Validation: JPEG only, max 2MB
- Response: `{ message, user: PublicUserSummary }`
- `GET /me`
- Auth: required
- Response: `PublicUserDetails`
### Deals (`/api/deals`)
List query params (used in list endpoints):
- `q` (string, default "")
- `page` (int, default 1)
- `limit` (int, default 10, max 100)
Endpoints:
- `GET /users/:userName/deals`
- Auth: optional
- Response: `DealListResponse`
- `GET /me/deals`
- Auth: required
- Response: `DealListResponse`
- `GET /new`
- `GET /hot`
- `GET /trending`
- `GET /` (same as `/new`)
- Auth: optional
- Response: `DealListResponse`
- `GET /search`
- Auth: optional
- If `q` is empty: `{ results: [], total: 0, totalPages: 0, page }`
- Else: `DealListResponse`
- `GET /top`
- Auth: optional
- Query: `range=day|week|month` (default `day`), `limit` (default 6, max 20)
- Response: `DealCard[]` (array only)
- `GET /:id`
- Response: `DealDetail`
- `POST /`
- Auth: required
- Multipart: `images` (array, max 5)
- Limits: 10MB per file (upload middleware)
- Body fields: `{ title, description?, url?, price?, sellerName? }`
- Response: `DealDetail`
### Category (`/api/category`)
- `GET /:slug`
- Response: `{ id, name, slug, description, breadcrumb }`
- `GET /:slug/deals`
- Auth: optional
- Query: `page`, `limit`, plus filters from query
- Response: `DealCard[]` (array only)
### Seller (`/api/seller`)
- `POST /from-link`
- Auth: required
- Body: `{ url }`
- Response: `{ found: boolean, seller: { id, name, url } | null }`
- `GET /:sellerName`
- Response: `{ id, name, url, logoUrl }`
- `GET /:sellerName/deals`
- Auth: optional
- Query: `page`, `limit`, `q`
- Response: `DealCard[]` (array only)
### Comments (`/api/comments`)
- `GET /:dealId`
- Response: `Comment[]`
- `POST /`
- Auth: required
- Body: `{ dealId, text, parentId? }`
- Response: `Comment`
- `DELETE /:id`
- Auth: required
- Response: `{ message: "Yorum silindi." }`
### Votes (`/api/vote` and `/api/deal-votes`)
- `POST /`
- Auth: required
- Body: `{ dealId, voteType }` where voteType is -1, 0, or 1
- Response: `VoteResponse`
- `GET /:dealId`
- Response: `VoteListResponse`
### Users (`/api/user` and `/api/users`)
- `GET /:userName`
- Response: `UserProfile`
### Mod (`/api/mod`) (MOD or ADMIN)
- `GET /deals/pending`
- Auth + Role: MOD
- Query: `page`, `limit`, `q` plus filters
- Response: `DealCard[]` (array only)
- `POST /deals/:id/approve`
- `POST /deals/:id/reject`
- `POST /deals/:id/expire`
- `POST /deals/:id/unexpire`
- Auth + Role: MOD
- Response: `{ id, status }`
## Notes for frontend
- Some list endpoints return full pagination object, some return only `results` array. Treat them separately:
- Full: `/api/deals` list endpoints, `/api/deals/search`, `/api/deals/users/:userName/deals`, `/api/deals/me/deals`
- Array only: `/api/deals/top`, `/api/category/:slug/deals`, `/api/seller/:sellerName/deals`, `/api/mod/deals/pending`
- Optional-auth endpoints return `401` if a token is provided but invalid.
- MyVote is only filled when Authorization header is present.
- There are duplicate route prefixes for users and votes. Frontend can use either, but pick one for consistency.

View File

@ -1,239 +1,242 @@
[
{ "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null },
{ "id": 0, "name": "Undefined", "slug": "undefined", "parentId": null, "description": "Henüz sınıflandırılmamış içerikler için geçici kategori." },
{ "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0 },
{ "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0 },
{ "id": 3, "name": "Gıda", "slug": "food", "parentId": 0 },
{ "id": 4, "name": "Oto", "slug": "auto", "parentId": 0 },
{ "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0 },
{ "id": 1, "name": "Elektronik", "slug": "electronics", "parentId": 0, "description": "Telefon, bilgisayar, TV, ses sistemleri ve diğer elektronik ürünler." },
{ "id": 2, "name": "Kozmetik", "slug": "beauty", "parentId": 0, "description": "Makyaj, cilt bakımı, saç bakımı, parfüm ve kişisel bakım ürünleri." },
{ "id": 3, "name": "Gıda", "slug": "food", "parentId": 0, "description": "Atıştırmalık, içecek, temel gıda ve market ürünleri." },
{ "id": 4, "name": "Oto", "slug": "auto", "parentId": 0, "description": "Araç bakım, yağ, yedek parça ve oto aksesuar ürünleri." },
{ "id": 5, "name": "Ev & Bahçe", "slug": "home-garden", "parentId": 0, "description": "Ev ihtiyaçları, dekorasyon, temizlik ve bahçe ürünleri." },
{ "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1 },
{ "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6 },
{ "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7 },
{ "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7 },
{ "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7 },
{ "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7 },
{ "id": 6, "name": "Bilgisayar", "slug": "computers", "parentId": 1, "description": "Masaüstü/dizüstü bilgisayarlar, tabletler ve bilgisayar ekipmanları." },
{ "id": 7, "name": "PC Bileşenleri", "slug": "pc-components", "parentId": 6, "description": "Bilgisayar toplama/yükseltme için işlemci, ekran kartı, RAM, depolama vb." },
{ "id": 8, "name": "RAM", "slug": "pc-ram", "parentId": 7, "description": "Bilgisayar performansını artırmaya yönelik bellek modülleri." },
{ "id": 9, "name": "SSD", "slug": "pc-ssd", "parentId": 7, "description": "Hızlı depolama çözümleri (NVMe/SATA) SSD diskler." },
{ "id": 10, "name": "CPU", "slug": "pc-cpu", "parentId": 7, "description": "Bilgisayar işlemcileri; performans, oyun ve iş kullanımına yönelik modeller." },
{ "id": 11, "name": "GPU", "slug": "pc-gpu", "parentId": 7, "description": "Ekran kartları; oyun, grafik tasarım ve video işleme için." },
{ "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6 },
{ "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12 },
{ "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12 },
{ "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6 },
{ "id": 12, "name": "Bilgisayar Aksesuarları", "slug": "pc-peripherals", "parentId": 6, "description": "Klavye, mouse, webcam, mikrofon, mousepad gibi çevre birimleri." },
{ "id": 13, "name": "Klavye", "slug": "pc-keyboard", "parentId": 12, "description": "Mekanik/membran, oyuncu ve ofis kullanımına uygun klavyeler." },
{ "id": 14, "name": "Mouse", "slug": "pc-mouse", "parentId": 12, "description": "Kablolu/kablosuz, oyuncu ve günlük kullanım mouse modelleri." },
{ "id": 15, "name": "Monitör", "slug": "pc-monitor", "parentId": 6, "description": "Bilgisayar monitörleri; oyun, ofis ve profesyonel kullanım seçenekleri." },
{ "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2 },
{ "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16 },
{ "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16 },
{ "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16 },
{ "id": 16, "name": "Makyaj", "slug": "beauty-makeup", "parentId": 2, "description": "Ruj, fondöten, maskara ve diğer makyaj ürünleri." },
{ "id": 17, "name": "Ruj", "slug": "beauty-lipstick", "parentId": 16, "description": "Mat, parlak, likit ve farklı renk seçeneklerinde dudak ürünleri." },
{ "id": 18, "name": "Fondöten", "slug": "beauty-foundation", "parentId": 16, "description": "Cilt tonunu eşitleyen; mat/parlak bitişli fondöten ürünleri." },
{ "id": 19, "name": "Maskara", "slug": "beauty-mascara", "parentId": 16, "description": "Kirpiklere hacim, uzunluk ve kıvrım kazandıran maskaralar." },
{ "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2 },
{ "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20 },
{ "id": 20, "name": "Cilt Bakımı", "slug": "beauty-skincare", "parentId": 2, "description": "Nemlendirici, temizleyici, serum, güneş kremi gibi cilt bakım ürünleri." },
{ "id": 21, "name": "Nemlendirici", "slug": "beauty-moisturizer", "parentId": 20, "description": "Cildi nemlendirip bariyeri destekleyen yüz/vücut nemlendiricileri." },
{ "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3 },
{ "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22 },
{ "id": 22, "name": "Atıştırmalık", "slug": "food-snacks", "parentId": 3, "description": "Cips, kuruyemiş, bisküvi, çikolata ve benzeri atıştırmalıklar." },
{ "id": 23, "name": "Çiğköfte", "slug": "food-cigkofte", "parentId": 22, "description": "Hazır çiğköfte ürünleri ve çiğköfte setleri." },
{ "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3 },
{ "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24 },
{ "id": 24, "name": "İçecek", "slug": "food-beverages", "parentId": 3, "description": "Kahve, çay, su, gazlı içecek ve diğer içecek ürünleri." },
{ "id": 25, "name": "Kahve", "slug": "food-coffee", "parentId": 24, "description": "Çekirdek/öğütülmüş, kapsül ve hazır kahve çeşitleri." },
{ "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4 },
{ "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26 },
{ "id": 26, "name": "Yağlar", "slug": "auto-oils", "parentId": 4, "description": "Motor yağı ve araç için kullanılan diğer yağ çeşitleri." },
{ "id": 27, "name": "Motor Yağı", "slug": "auto-engine-oil", "parentId": 26, "description": "Motoru koruyan; farklı viskozite ve onaylara sahip motor yağları." },
{ "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4 },
{ "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28 },
{ "id": 28, "name": "Oto Parçaları", "slug": "auto-parts", "parentId": 4, "description": "Fren, filtre, aydınlatma ve diğer araç yedek parça ürünleri." },
{ "id": 29, "name": "Fren Balatası", "slug": "auto-brake-pads", "parentId": 28, "description": "Araç fren sistemi için ön/arka fren balatası ürünleri." },
{ "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5 },
{ "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30 },
{ "id": 30, "name": "Bahçe", "slug": "home-garden-garden", "parentId": 5, "description": "Bahçe bakımı, sulama ve dış mekân düzenleme ürünleri." },
{ "id": 31, "name": "Sulama", "slug": "garden-irrigation", "parentId": 30, "description": "Hortum, damla sulama, sprinkler ve sulama ekipmanları." },
{ "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1 },
{ "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32 },
{ "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32 },
{ "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32 },
{ "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32 },
{ "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32 },
{ "id": 32, "name": "Telefon & Aksesuarları", "slug": "phone", "parentId": 1, "description": "Akıllı telefonlar ve telefonla ilgili tüm aksesuarlar." },
{ "id": 33, "name": "Akıllı Telefon", "slug": "phone-smartphone", "parentId": 32, "description": "Android/iOS akıllı telefonlar ve farklı marka/model seçenekleri." },
{ "id": 34, "name": "Telefon Kılıfı", "slug": "phone-case", "parentId": 32, "description": "Cihazı koruyan silikon, sert kapak, cüzdan tipi telefon kılıfları." },
{ "id": 35, "name": "Ekran Koruyucu", "slug": "phone-screen-protector", "parentId": 32, "description": "Cam/film ekran koruyucular; çizilme ve darbe koruması sağlar." },
{ "id": 36, "name": "Şarj & Kablo", "slug": "phone-charging", "parentId": 32, "description": "Şarj adaptörü, kablo, hızlı şarj ekipmanları ve aksesuarları." },
{ "id": 37, "name": "Powerbank", "slug": "phone-powerbank", "parentId": 32, "description": "Taşınabilir şarj cihazları; farklı kapasite ve hızlı şarj destekleri." },
{ "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1 },
{ "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38 },
{ "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38 },
{ "id": 38, "name": "Giyilebilir Teknoloji", "slug": "wearables", "parentId": 1, "description": "Akıllı saat, bileklik ve sağlık/aktivite takibi yapan cihazlar." },
{ "id": 39, "name": "Akıllı Saat", "slug": "wearables-smartwatch", "parentId": 38, "description": "Bildirim, sağlık takibi ve uygulama desteği sunan akıllı saatler." },
{ "id": 40, "name": "Akıllı Bileklik", "slug": "wearables-band", "parentId": 38, "description": "Adım, uyku, nabız gibi metrikleri takip eden akıllı bileklikler." },
{ "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1 },
{ "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41 },
{ "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42 },
{ "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41 },
{ "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41 },
{ "id": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41 },
{ "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41 },
{ "id": 41, "name": "Ses & Audio", "slug": "audio", "parentId": 1, "description": "Kulaklık, hoparlör, mikrofon, soundbar ve ses ekipmanları." },
{ "id": 42, "name": "Kulaklık", "slug": "audio-headphones", "parentId": 41, "description": "Kulak üstü, kulak içi, kablolu/kablosuz kulaklık modelleri." },
{ "id": 43, "name": "TWS Kulaklık", "slug": "audio-tws", "parentId": 42, "description": "Tam kablosuz (True Wireless) kulak içi kulaklıklar." },
{ "id": 44, "name": "Bluetooth Hoparlör", "slug": "audio-bt-speaker", "parentId": 41, "description": "Taşınabilir kablosuz hoparlörler; ev ve dış mekân kullanımı için." },
{ "id": 45, "name": "Soundbar", "slug": "audio-soundbar", "parentId": 41, "description": "TV için daha güçlü ve net ses sağlayan soundbar sistemleri." },
{ "id": 46, "name": "Mikrofon", "slug": "audio-microphone", "parentId": 41, "description": "Yayın, toplantı ve kayıt amaçlı masaüstü/yalaka mikrofonlar." },
{ "id": 47, "name": "Plak / Pikap", "slug": "audio-turntable", "parentId": 41, "description": "Vinyl plak ve pikap ürünleri; analog müzik ekipmanları." },
{ "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1 },
{ "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48 },
{ "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48 },
{ "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48 },
{ "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48 },
{ "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48 },
{ "id": 48, "name": "TV & Video", "slug": "tv-video", "parentId": 1, "description": "Televizyonlar, projeksiyonlar, medya oynatıcılar ve TV aksesuarları." },
{ "id": 49, "name": "Televizyon", "slug": "tv", "parentId": 48, "description": "LED/QLED/OLED televizyonlar; farklı boyut ve çözünürlük seçenekleri." },
{ "id": 50, "name": "Projeksiyon", "slug": "projector", "parentId": 48, "description": "Ev sineması ve sunum amaçlı projeksiyon cihazları." },
{ "id": 54, "name": "Oyun Konsolları", "slug": "console", "parentId": 1 },
{ "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54 },
{ "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54 },
{ "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54 },
{ "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54 },
{ "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54 },
{ "id": 51, "name": "Medya Oynatıcı", "slug": "tv-media-player", "parentId": 48, "description": "TVye bağlanıp uygulama/film/dizi oynatmayı sağlayan medya cihazları (Android TV box vb.)." },
{ "id": 52, "name": "TV Aksesuarları", "slug": "tv-accessories", "parentId": 48, "description": "TV için kumanda, askı aparatı, kablo, stand ve benzeri yardımcı aksesuarlar." },
{ "id": 53, "name": "Uydu Alıcısı / Receiver", "slug": "tv-receiver", "parentId": 48, "description": "Uydu yayını izlemek için receiver/uydu alıcısı ve ilgili cihazlar." },
{ "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1 },
{ "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60 },
{ "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60 },
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60 },
{ "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60 },
{ "id": 54, "name": "Konsollar", "slug": "console", "parentId": 191, "description": "PlayStation, Xbox, Nintendo konsolları; konsol oyunları ve aksesuarları." },
{ "id": 55, "name": "PlayStation", "slug": "console-playstation", "parentId": 54, "description": "PlayStation konsolları, oyunları, üyelikleri ve PlayStation aksesuarları." },
{ "id": 56, "name": "Xbox", "slug": "console-xbox", "parentId": 54, "description": "Xbox konsolları, oyunları, Game Pass/abonelik ve Xbox aksesuarları." },
{ "id": 57, "name": "Nintendo", "slug": "console-nintendo", "parentId": 54, "description": "Nintendo konsolları (Switch vb.), oyunları ve Nintendo aksesuarları." },
{ "id": 58, "name": "Oyunlar (Konsol)", "slug": "console-games", "parentId": 54, "description": "Konsollar için fiziksel/dijital oyunlar ve oyun içerikleri." },
{ "id": 59, "name": "Konsol Aksesuarları", "slug": "console-accessories", "parentId": 54, "description": "Kollar, şarj istasyonları, kulaklıklar, taşıma çantaları ve diğer konsol aksesuarları." },
{ "id": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1 },
{ "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65 },
{ "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65 },
{ "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65 },
{ "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65 },
{ "id": 60, "name": "Kamera & Fotoğraf", "slug": "camera", "parentId": 1, "description": "Fotoğraf/video çekim ekipmanları; kamera gövdeleri, lensler ve aksesuarlar." },
{ "id": 61, "name": "Fotoğraf Makinesi", "slug": "camera-photo", "parentId": 60, "description": "DSLR, aynasız ve kompakt fotoğraf makineleri." },
{ "id": 62, "name": "Aksiyon Kamera", "slug": "camera-action", "parentId": 60, "description": "GoPro tarzı dayanıklı, suya dayanıklı ve hareketli çekime uygun aksiyon kameraları." },
{ "id": 63, "name": "Lens", "slug": "camera-lens", "parentId": 60, "description": "Kamera lensleri; prime/zoom, geniş açı, tele, portre ve benzeri seçenekler." },
{ "id": 64, "name": "Tripod", "slug": "camera-tripod", "parentId": 60, "description": "Fotoğraf/video için tripod, monopod ve stabil çekim destek ekipmanları." },
{ "id": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6 },
{ "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70 },
{ "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70 },
{ "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70 },
{ "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70 },
{ "id": 65, "name": "Akıllı Ev", "slug": "smart-home", "parentId": 1, "description": "Ev otomasyonu ürünleri; aydınlatma, priz, sensör ve güvenlik çözümleri." },
{ "id": 66, "name": "Güvenlik Kamerası", "slug": "smart-security-camera", "parentId": 65, "description": "Ev/ofis için IP kamera, iç/dış kamera ve izleme sistemleri." },
{ "id": 67, "name": "Akıllı Priz", "slug": "smart-plug", "parentId": 65, "description": "Uygulama ile kontrol edilen, zamanlayıcı/enerji takibi sunan akıllı prizler." },
{ "id": 68, "name": "Akıllı Ampul", "slug": "smart-bulb", "parentId": 65, "description": "Renk/ışık şiddeti kontrolü yapılabilen, Wi-Fi/Zigbee akıllı ampuller." },
{ "id": 69, "name": "Akıllı Sensör", "slug": "smart-sensor", "parentId": 65, "description": "Kapı/pencere, hareket, sıcaklık/nem gibi verileri ölçen akıllı sensörler." },
{ "id": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6 },
{ "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75 },
{ "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75 },
{ "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75 },
{ "id": 70, "name": "Ağ Ürünleri", "slug": "pc-networking", "parentId": 6, "description": "İnternet ve yerel ağ kurulum ürünleri; router, modem, switch, menzil genişletici." },
{ "id": 71, "name": "Router", "slug": "pc-router", "parentId": 70, "description": "Kablosuz ağ dağıtımı için router cihazları (Wi-Fi 5/6/6E/7 vb.)." },
{ "id": 72, "name": "Modem", "slug": "pc-modem", "parentId": 70, "description": "DSL/VDSL/FTTH uyumlu modemler ve modem-router cihazları." },
{ "id": 73, "name": "Switch", "slug": "pc-switch", "parentId": 70, "description": "Kablolu ağ için port çoğaltan network switch cihazları." },
{ "id": 74, "name": "Wi-Fi Extender", "slug": "pc-wifi-extender", "parentId": 70, "description": "Kablosuz ağ menzilini artıran repeater/extender ve mesh uyumlu cihazlar." },
{ "id": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6 },
{ "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6 },
{ "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6 },
{ "id": 75, "name": "Yazıcı & Tarayıcı", "slug": "pc-printing", "parentId": 6, "description": "Ev/ofis baskı ve tarama ürünleri; yazıcı, tarayıcı ve sarf malzemeleri." },
{ "id": 76, "name": "Yazıcı", "slug": "pc-printer", "parentId": 75, "description": "Lazer/mürekkep püskürtmeli yazıcılar ve çok fonksiyonlu cihazlar." },
{ "id": 77, "name": "Toner & Kartuş", "slug": "pc-ink-toner", "parentId": 75, "description": "Yazıcılar için toner, kartuş, mürekkep ve ilgili sarf malzemeleri." },
{ "id": 78, "name": "Tarayıcı", "slug": "pc-scanner", "parentId": 75, "description": "Belge ve fotoğraf taraması için flatbed/ADF tarayıcı cihazları." },
{ "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6 },
{ "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82 },
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82 },
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82 },
{ "id": 79, "name": "Dizüstü Bilgisayar", "slug": "pc-laptop", "parentId": 6, "description": "Taşınabilir dizüstü bilgisayarlar; günlük, oyun ve iş amaçlı modeller." },
{ "id": 80, "name": "Masaüstü Bilgisayar", "slug": "pc-desktop", "parentId": 6, "description": "Hazır masaüstü bilgisayarlar ve iş/oyun odaklı sistemler." },
{ "id": 81, "name": "Tablet", "slug": "pc-tablet", "parentId": 6, "description": "Android/iPadOS/Windows tabletler ve tablet benzeri cihazlar." },
{ "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12 },
{ "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12 },
{ "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12 },
{ "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12 },
{ "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12 },
{ "id": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12 },
{ "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12 },
{ "id": 82, "name": "Depolama", "slug": "pc-storage", "parentId": 6, "description": "Harici disk, USB bellek, NAS ve diğer depolama çözümleri." },
{ "id": 83, "name": "Harici Disk", "slug": "pc-external-drive", "parentId": 82, "description": "Taşınabilir harici HDD/SSD diskler ve yedekleme çözümleri." },
{ "id": 84, "name": "USB Flash", "slug": "pc-usb-drive", "parentId": 82, "description": "USB bellekler; farklı kapasite ve hız seçenekleri." },
{ "id": 85, "name": "NAS", "slug": "pc-nas", "parentId": 82, "description": "Ağ üzerinden depolama ve yedekleme için NAS cihazları ve disk kutuları." },
{ "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7 },
{ "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7 },
{ "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7 },
{ "id": 86, "name": "Webcam", "slug": "pc-webcam", "parentId": 12, "description": "Görüntülü görüşme ve yayın için web kameraları (1080p/2K/4K vb.)." },
{ "id": 87, "name": "Hoparlör (PC)", "slug": "pc-speaker", "parentId": 12, "description": "Bilgisayar için masaüstü hoparlör sistemleri ve ses çözümleri." },
{ "id": 88, "name": "Mikrofon (PC)", "slug": "pc-mic", "parentId": 12, "description": "Oyun, yayın, toplantı ve kayıt için PC uyumlu mikrofonlar." },
{ "id": 89, "name": "Mousepad", "slug": "pc-mousepad", "parentId": 12, "description": "Mouse kullanımını iyileştiren, farklı boyut ve yüzey tiplerinde mousepadler." },
{ "id": 90, "name": "Dock / USB Hub", "slug": "pc-dock-hub", "parentId": 12, "description": "Port çoğaltma için USB hub ve laptop dock istasyonları." },
{ "id": 91, "name": "Laptop Çantası", "slug": "pc-laptop-bag", "parentId": 12, "description": "Dizüstü bilgisayar taşıma çantaları, kılıflar ve koruyucu çantalar." },
{ "id": 92, "name": "Gamepad / Controller", "slug": "pc-controller", "parentId": 12, "description": "PC ile uyumlu oyun kolları ve kontrolcü aksesuarları." },
{ "id": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7 },
{ "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96 },
{ "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96 },
{ "id": 93, "name": "Anakart", "slug": "pc-motherboard", "parentId": 7, "description": "İşlemci soketi ve chipsete göre PC anakartları (ATX/mATX/ITX)." },
{ "id": 94, "name": "Güç Kaynağı (PSU)", "slug": "pc-psu", "parentId": 7, "description": "Bilgisayar bileşenlerini besleyen PSU güç kaynakları (80+ sertifikalı vb.)." },
{ "id": 95, "name": "Kasa", "slug": "pc-case", "parentId": 7, "description": "Bilgisayar kasaları; hava akışı, boyut ve tasarıma göre seçenekler." },
{ "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2 },
{ "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99 },
{ "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99 },
{ "id": 96, "name": "Soğutma", "slug": "pc-cooling", "parentId": 7, "description": "CPU/GPU ve kasa soğutma çözümleri; fanlar, sıvı soğutma ve aksesuarlar." },
{ "id": 97, "name": "Kasa Fanı", "slug": "pc-fan", "parentId": 96, "description": "Kasa içi hava akışı için fanlar (RGB/PWM vb. seçenekler)." },
{ "id": 98, "name": "Sıvı Soğutma", "slug": "pc-liquid-cooling", "parentId": 96, "description": "AIO ve özel loop sıvı soğutma çözümleri ve bileşenleri." },
{ "id": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2 },
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102 },
{ "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102 },
{ "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102 },
{ "id": 99, "name": "Parfüm", "slug": "beauty-fragrance", "parentId": 2, "description": "Kadın/erkek parfümleri, deodorantlar ve koku ürünleri." },
{ "id": 100, "name": "Kadın Parfüm", "slug": "beauty-fragrance-women", "parentId": 99, "description": "Kadınlara yönelik parfümler; EDT/EDP ve farklı koku profilleri." },
{ "id": 101, "name": "Erkek Parfüm", "slug": "beauty-fragrance-men", "parentId": 99, "description": "Erkeklere yönelik parfümler; EDT/EDP, fresh/odunsu/baharatlı koku seçenekleri." },
{ "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2 },
{ "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106 },
{ "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106 },
{ "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106 },
{ "id": 102, "name": "Saç Bakımı", "slug": "beauty-haircare", "parentId": 2, "description": "Saç temizliği, onarımı ve şekillendirme için saç bakım ürünleri." },
{ "id": 103, "name": "Şampuan", "slug": "beauty-shampoo", "parentId": 102, "description": "Kepek, yağlı/kuru saç, onarıcı ve renk koruyucu şampuan çeşitleri." },
{ "id": 104, "name": "Saç Kremi", "slug": "beauty-conditioner", "parentId": 102, "description": "Saçı yumuşatan, kolay tarama sağlayan ve bakım yapan saç kremleri." },
{ "id": 105, "name": "Saç Şekillendirici", "slug": "beauty-hair-styling", "parentId": 102, "description": "Wax, jel, köpük, sprey ve ısı koruyucu gibi şekillendirici ürünler." },
{ "id": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20 },
{ "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20 },
{ "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20 },
{ "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20 },
{ "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20 },
{ "id": 106, "name": "Kişisel Bakım", "slug": "beauty-personal-care", "parentId": 2, "description": "Günlük hijyen ve bakım ürünleri; deodorant, tıraş ve epilasyon gibi." },
{ "id": 107, "name": "Deodorant", "slug": "beauty-deodorant", "parentId": 106, "description": "Ter kokusunu önlemeye yardımcı roll-on, sprey ve stick deodorantlar." },
{ "id": 108, "name": "Tıraş Ürünleri", "slug": "beauty-shaving", "parentId": 106, "description": "Tıraş köpüğü/jeli, losyon, aftershave ve tıraş bıçağı ürünleri." },
{ "id": 109, "name": "Ağda / Epilasyon", "slug": "beauty-hair-removal", "parentId": 106, "description": "Ağda bantları, ağda ürünleri, epilatör ve tüy alma yardımcıları." },
{ "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3 },
{ "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115 },
{ "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115 },
{ "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115 },
{ "id": 110, "name": "Serum", "slug": "beauty-skincare-serum", "parentId": 20, "description": "Leke, nem, anti-aging ve aydınlatma için yoğun içerikli cilt serumları." },
{ "id": 111, "name": "Güneş Kremi", "slug": "beauty-sunscreen", "parentId": 20, "description": "UVA/UVB koruması sağlayan yüz ve vücut güneş koruyucuları (SPF)." },
{ "id": 112, "name": "Temizleyici", "slug": "beauty-cleanser", "parentId": 20, "description": "Jel, köpük, yağ bazlı ve micellar gibi yüz temizleme ürünleri." },
{ "id": 113, "name": "Yüz Maskesi", "slug": "beauty-mask", "parentId": 20, "description": "Kil, kağıt ve gece maskeleri; nem, arındırma ve bakım amaçlı." },
{ "id": 114, "name": "Tonik", "slug": "beauty-toner", "parentId": 20, "description": "Cildi dengeleyen, gözenek görünümünü destekleyen tonik ürünleri." },
{ "id": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3 },
{ "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119 },
{ "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119 },
{ "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119 },
{ "id": 115, "name": "Temel Gıda", "slug": "food-staples", "parentId": 3, "description": "Günlük mutfak ihtiyaçları; makarna, bakliyat, yağ ve benzeri temel ürünler." },
{ "id": 116, "name": "Makarna", "slug": "food-pasta", "parentId": 115, "description": "Spagetti, penne, erişte ve farklı çeşitlerde makarna ürünleri." },
{ "id": 117, "name": "Pirinç & Bakliyat", "slug": "food-legumes", "parentId": 115, "description": "Pirinç, bulgur, mercimek, nohut, fasulye ve diğer bakliyatlar." },
{ "id": 118, "name": "Yağ & Sirke (Gıda)", "slug": "food-oil-vinegar", "parentId": 115, "description": "Zeytinyağı, ayçiçek yağı ve çeşitli sirke türleri gibi ürünler." },
{ "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24 },
{ "id": 124, "name": "Su", "slug": "food-water", "parentId": 24 },
{ "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24 },
{ "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24 },
{ "id": 119, "name": "Kahvaltılık", "slug": "food-breakfast", "parentId": 3, "description": "Peynir, zeytin, reçel, bal ve diğer kahvaltılık ürünler." },
{ "id": 120, "name": "Peynir", "slug": "food-cheese", "parentId": 119, "description": "Beyaz peynir, kaşar, tulum ve farklı peynir çeşitleri." },
{ "id": 121, "name": "Zeytin", "slug": "food-olive", "parentId": 119, "description": "Siyah/yeşil, çekirdekli/çekirdeksiz ve salamura zeytin çeşitleri." },
{ "id": 122, "name": "Reçel & Bal", "slug": "food-jam-honey", "parentId": 119, "description": "Kahvaltılık reçeller, marmelatlar, bal ve benzeri tatlandırıcı ürünler." },
{ "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3 },
{ "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3 },
{ "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3 },
{ "id": 123, "name": "Gazlı İçecek", "slug": "food-soda", "parentId": 24, "description": "Kola, gazoz, aromalı soda ve benzeri gazlı içecekler." },
{ "id": 124, "name": "Su", "slug": "food-water", "parentId": 24, "description": "Pet şişe, damacana ve aromalı su seçenekleri." },
{ "id": 125, "name": "Enerji İçeceği", "slug": "food-energy", "parentId": 24, "description": "Enerji içecekleri; farklı hacim ve kafein/taurin içerikli seçenekler." },
{ "id": 126, "name": "Çay", "slug": "food-tea", "parentId": 24, "description": "Siyah çay, yeşil çay, bitki çayları ve aromalı çay çeşitleri." },
{ "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4 },
{ "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130 },
{ "id": 127, "name": "Dondurulmuş", "slug": "food-frozen", "parentId": 3, "description": "Dondurulmuş gıdalar; sebze, hazır ürünler ve dondurulmuş atıştırmalıklar." },
{ "id": 128, "name": "Et & Tavuk", "slug": "food-meat", "parentId": 3, "description": "Kırmızı et, tavuk ve işlenmiş et ürünleri; paketli market seçenekleri." },
{ "id": 129, "name": "Tatlı", "slug": "food-dessert", "parentId": 3, "description": "Pastane/market tatlıları, çikolata bazlı ürünler ve tatlı çeşitleri." },
{ "id": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4 },
{ "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132 },
{ "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4 },
{ "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4 },
{ "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130 },
{ "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130 },
{ "id": 130, "name": "Oto Aksesuarları", "slug": "auto-accessories", "parentId": 4, "description": "Araç içi/dışı kullanım için aksesuarlar; düzenleyici, tutucu, bakım setleri vb." },
{ "id": 131, "name": "Araç İçi Elektronik", "slug": "auto-in-car-electronics", "parentId": 130, "description": "Araç içi kamera, multimedya, şarj cihazı, FM transmitter gibi elektronik ürünler." },
{ "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5 },
{ "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138 },
{ "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138 },
{ "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138 },
{ "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138 },
{ "id": 132, "name": "Oto Bakım", "slug": "auto-care", "parentId": 4, "description": "Araç bakım ürünleri; cila, wax, kaplama, temizlik ve koruma çözümleri." },
{ "id": 133, "name": "Oto Temizlik", "slug": "auto-cleaning", "parentId": 132, "description": "İç/dış temizlik ürünleri; şampuan, köpük, bez, fırça ve temizleyiciler." },
{ "id": 134, "name": "Lastik & Jant", "slug": "auto-tires", "parentId": 4, "description": "Lastik, jant ve ilgili aksesuarlar; mevsimlik lastikler ve bakım ürünleri." },
{ "id": 135, "name": "Akü", "slug": "auto-battery", "parentId": 4, "description": "Otomobil aküleri ve akü takviye/şarj ekipmanları." },
{ "id": 136, "name": "Oto Aydınlatma", "slug": "auto-lighting", "parentId": 130, "description": "Far ampulü, LED dönüşüm kitleri ve araç iç/dış aydınlatma ürünleri." },
{ "id": 137, "name": "Oto Ses Sistemi", "slug": "auto-audio", "parentId": 130, "description": "Teyp, hoparlör, amfi, subwoofer ve araç ses sistemi ekipmanları." },
{ "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5 },
{ "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143 },
{ "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143 },
{ "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143 },
{ "id": 138, "name": "Mobilya", "slug": "home-furniture", "parentId": 5, "description": "Ev mobilyaları; masa, sandalye, koltuk, yatak ve depolama ürünleri." },
{ "id": 139, "name": "Yemek Masası", "slug": "home-dining-table", "parentId": 138, "description": "Mutfak/yemek odası için farklı boyut ve malzemelerde yemek masaları." },
{ "id": 140, "name": "Sandalye", "slug": "home-chair", "parentId": 138, "description": "Yemek odası, çalışma ve çok amaçlı kullanım için sandalyeler." },
{ "id": 141, "name": "Koltuk", "slug": "home-sofa", "parentId": 138, "description": "Oturma odası için koltuk, kanepe ve oturma grubu ürünleri." },
{ "id": 142, "name": "Yatak", "slug": "home-bed", "parentId": 138, "description": "Tek/çift kişilik yatak bazası, karyola ve yatak sistemleri." },
{ "id": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5 },
{ "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147 },
{ "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147 },
{ "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149 },
{ "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149 },
{ "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149 },
{ "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149 },
{ "id": 143, "name": "Ev Tekstili", "slug": "home-textile", "parentId": 5, "description": "Nevresim, battaniye, perde ve diğer ev tekstili ürünleri." },
{ "id": 144, "name": "Nevresim", "slug": "home-bedding", "parentId": 143, "description": "Nevresim takımları, çarşaflar ve yastık kılıfları." },
{ "id": 145, "name": "Yorgan & Battaniye", "slug": "home-blanket", "parentId": 143, "description": "Isı ve konfor sağlayan yorgan, battaniye ve uyku ürünleri." },
{ "id": 146, "name": "Perde", "slug": "home-curtain", "parentId": 143, "description": "Tül, fon ve stor gibi farklı perde çeşitleri ve aksesuarları." },
{ "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5 },
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5 },
{ "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155 },
{ "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155 },
{ "id": 147, "name": "Mutfak", "slug": "home-kitchen", "parentId": 5, "description": "Mutfak gereçleri, pişirme ekipmanları ve küçük ev aletleri." },
{ "id": 148, "name": "Tencere & Tava", "slug": "home-cookware", "parentId": 147, "description": "Tencere setleri, tava çeşitleri ve pişirme ekipmanları." },
{ "id": 149, "name": "Küçük Ev Aletleri", "slug": "home-small-appliances", "parentId": 147, "description": "Mutfakta kullanılan küçük elektrikli aletler; kahve makinesi, blender vb." },
{ "id": 150, "name": "Kahve Makinesi", "slug": "home-coffee-machine", "parentId": 149, "description": "Filtre, espresso, kapsül ve Türk kahvesi makineleri." },
{ "id": 151, "name": "Blender", "slug": "home-blender", "parentId": 149, "description": "Smoothie, çorba ve karıştırma işlemleri için blender ve el blender setleri." },
{ "id": 152, "name": "Airfryer", "slug": "home-airfryer", "parentId": 149, "description": "Az yağ ile pişirme yapmaya yarayan airfryer cihazları ve aksesuarları." },
{ "id": 153, "name": "Süpürge", "slug": "home-vacuum", "parentId": 149, "description": "Dikey, toz torbalı/torbasız ve robot süpürge dahil ev süpürgeleri." },
{ "id": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5 },
{ "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158 },
{ "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158 },
{ "id": 154, "name": "Aydınlatma", "slug": "home-lighting", "parentId": 5, "description": "Avize, lambader, masa lambası ve LED aydınlatma çözümleri." },
{ "id": 155, "name": "Dekorasyon", "slug": "home-decor", "parentId": 5, "description": "Evi kişiselleştiren dekoratif ürünler; aksesuar, tablo, obje ve benzerleri." },
{ "id": 156, "name": "Halı", "slug": "home-rug", "parentId": 155, "description": "Salon, koridor ve oda için halılar; farklı ölçü ve materyal seçenekleri." },
{ "id": 157, "name": "Duvar Dekoru", "slug": "home-wall-decor", "parentId": 155, "description": "Tablo, raf, ayna, sticker ve benzeri duvar dekor ürünleri." },
{ "id": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5 },
{ "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161 },
{ "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161 },
{ "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161 },
{ "id": 158, "name": "Temizlik", "slug": "home-cleaning", "parentId": 5, "description": "Ev temizliği için ürünler; deterjan, bez, sünger ve temizlik ekipmanları." },
{ "id": 159, "name": "Deterjan", "slug": "home-detergent", "parentId": 158, "description": "Çamaşır, bulaşık ve yüzey temizliği için deterjan ve temizlik kimyasalları." },
{ "id": 160, "name": "Kağıt Ürünleri", "slug": "home-paper-products", "parentId": 158, "description": "Tuvalet kağıdı, kağıt havlu, peçete ve benzeri kağıt temizlik ürünleri." },
{ "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5 },
{ "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165 },
{ "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165 },
{ "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165 },
{ "id": 161, "name": "El Aletleri", "slug": "home-tools", "parentId": 5, "description": "Ev ve hobi işleri için el aletleri, tamir ve montaj ekipmanları." },
{ "id": 162, "name": "Matkap", "slug": "home-drill", "parentId": 161, "description": "Darbeli/darbesiz, şarjlı/kablolu matkap ve vidalama makineleri." },
{ "id": 163, "name": "Testere", "slug": "home-saw", "parentId": 161, "description": "Ahşap/metal kesim için el testereleri ve elektrikli testere çeşitleri." },
{ "id": 164, "name": "Vida & Dübel", "slug": "home-hardware", "parentId": 161, "description": "Montaj ve sabitleme için vida, dübel, bağlantı elemanları ve setler." },
{ "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0 },
{ "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169 },
{ "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170 },
{ "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169 },
{ "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169 },
{ "id": 165, "name": "Evcil Hayvan", "slug": "pet", "parentId": 5, "description": "Kedi, köpek ve diğer evcil hayvanlar için mama, bakım ve ihtiyaç ürünleri." },
{ "id": 166, "name": "Kedi Maması", "slug": "pet-cat-food", "parentId": 165, "description": "Yavru/yetişkin kedi için kuru/yaş mama ve özel diyet mamaları." },
{ "id": 167, "name": "Köpek Maması", "slug": "pet-dog-food", "parentId": 165, "description": "Yavru/yetişkin köpek için kuru/yaş mama ve özel ihtiyaç mamaları." },
{ "id": 168, "name": "Kedi Kumu", "slug": "pet-cat-litter", "parentId": 165, "description": "Topaklanan/silikalı/bitkisel kedi kumları ve koku kontrol çözümleri." },
{ "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0 },
{ "id": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174 },
{ "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174 },
{ "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174 },
{ "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174 },
{ "id": 169, "name": "Kırtasiye & Ofis", "slug": "office", "parentId": 0, "description": "Okul ve ofis ihtiyaçları; kağıt ürünleri, yazım gereçleri ve aksesuarlar." },
{ "id": 170, "name": "Kağıt & Defter", "slug": "office-paper-notebook", "parentId": 169, "description": "Defter, ajanda, not kağıdı ve farklı türde kağıt ürünleri." },
{ "id": 171, "name": "A4 Kağıdı", "slug": "office-a4-paper", "parentId": 170, "description": "Yazıcı ve fotokopi için A4 kağıt; farklı gramaj ve kalite seçenekleri." },
{ "id": 172, "name": "Kalem", "slug": "office-pen", "parentId": 169, "description": "Tükenmez, jel, kurşun, marker ve farklı amaçlara uygun kalemler." },
{ "id": 173, "name": "Okul Çantası", "slug": "office-school-bag", "parentId": 169, "description": "Öğrenciler için sırt çantası, beslenme çantası ve okul çantaları." },
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0 },
{ "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179 },
{ "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179 },
{ "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179 },
{ "id": 174, "name": "Bebek & Çocuk", "slug": "baby", "parentId": 0, "description": "Bebek ve çocuk bakım/bez, mama, ıslak mendil ve oyuncak ürünleri." },
{ "id": 175, "name": "Bebek Bezi", "slug": "baby-diaper", "parentId": 174, "description": "Yeni doğan ve farklı bedenlerde bebek bezleri, külot bez seçenekleri." },
{ "id": 176, "name": "Islak Mendil", "slug": "baby-wipes", "parentId": 174, "description": "Bebek bakımı için ıslak mendil; hassas cilt uyumlu seçenekler." },
{ "id": 177, "name": "Bebek Maması", "slug": "baby-food", "parentId": 174, "description": "Bebekler için mama, ek gıda ve püre ürünleri." },
{ "id": 178, "name": "Oyuncak", "slug": "baby-toys", "parentId": 174, "description": "Bebek ve çocuklar için eğitici, zeka ve oyun oyuncakları." },
{ "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0 },
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183 },
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183 },
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183 },
{ "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183 },
{ "id": 179, "name": "Spor & Outdoor", "slug": "sports", "parentId": 0, "description": "Spor ekipmanları ve outdoor ürünleri; kamp, fitness, bisiklet ve daha fazlası." },
{ "id": 180, "name": "Kamp", "slug": "sports-camping", "parentId": 179, "description": "Çadır, uyku tulumu, kamp sandalyesi ve kamp ekipmanları." },
{ "id": 181, "name": "Fitness", "slug": "sports-fitness", "parentId": 179, "description": "Ağırlık, dambıl, mat ve evde antrenman için fitness ekipmanları." },
{ "id": 182, "name": "Bisiklet", "slug": "sports-bicycle", "parentId": 179, "description": "Şehir/dağ/katlanır bisikletler ve bisiklet aksesuarları." },
{ "id": 183, "name": "Moda", "slug": "fashion", "parentId": 0, "description": "Giyim, ayakkabı ve aksesuar ürünleri; kadın/erkek moda kategorileri." },
{ "id": 184, "name": "Ayakkabı", "slug": "fashion-shoes", "parentId": 183, "description": "Spor ayakkabı, günlük ayakkabı ve farklı kullanım amaçlarına uygun modeller." },
{ "id": 185, "name": "Erkek Giyim", "slug": "fashion-men", "parentId": 183, "description": "Erkek kıyafetleri; tişört, gömlek, pantolon, mont ve daha fazlası." },
{ "id": 186, "name": "Kadın Giyim", "slug": "fashion-women", "parentId": 183, "description": "Kadın kıyafetleri; elbise, bluz, pantolon, mont ve daha fazlası." },
{ "id": 187, "name": "Çanta", "slug": "fashion-bags", "parentId": 183, "description": "Sırt çantası, el çantası, valiz ve farklı kullanım amaçlı çantalar." },
{ "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0, "description": "Kitaplar, dijital içerikler, oyun ve medya ürünleri." },
{ "id": 189, "name": "Kitap", "slug": "books", "parentId": 188, "description": "Roman, kişisel gelişim, eğitim ve diğer türlerde basılı kitaplar." },
{ "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 191, "description": "PC/konsol platformları için dijital oyunlar, kodlar ve dijital içerikler." },
{ "id": 191, "name": "Oyun", "slug": "games", "parentId": 0, "description": "Konsol, PC ve dijital oyun fırsatları; oyun ekipmanları ve abonelikler." }
{ "id": 188, "name": "Kitap & Medya", "slug": "books-media", "parentId": 0 },
{ "id": 189, "name": "Kitap", "slug": "books", "parentId": 188 },
{ "id": 190, "name": "Dijital Oyun (Genel)", "slug": "digital-games", "parentId": 188 }
]

230
prisma/deals.json Normal file
View File

@ -0,0 +1,230 @@
[
{
"title": "Samsung 990 PRO 1TB NVMe SSD",
"price": 3299.99,
"originalPrice": 3799.99,
"url": "https://example.com/samsung-990pro-1tb",
"q": "nvme ssd"
},
{
"title": "Logitech MX Master 3S Mouse",
"price": 2499.9,
"originalPrice": 2999.9,
"url": "https://example.com/mx-master-3s",
"q": "wireless mouse"
},
{
"title": "Sony WH-1000XM5 Kulaklık",
"price": 9999.0,
"originalPrice": 11999.0,
"shippingPrice": 0,
"url": "https://example.com/sony-xm5",
"q": "headphones"
},
{
"title": "Apple AirPods Pro 2",
"price": 8499.0,
"originalPrice": 9999.0,
"url": "https://example.com/airpods-pro-2",
"q": "earbuds"
},
{
"title": "Anker 65W GaN Şarj Aleti",
"price": 899.0,
"shippingPrice": 39.9,
"url": "https://example.com/anker-65w-gan",
"q": "charger"
},
{
"title": "Kindle Paperwhite 16GB",
"price": 5199.0,
"originalPrice": 5999.0,
"shippingPrice": 0,
"url": "https://example.com/kindle-paperwhite",
"q": "ebook reader"
},
{
"title": "Dell 27\" 144Hz Monitör",
"price": 7999.0,
"originalPrice": 9499.0,
"shippingPrice": 0,
"url": "https://example.com/dell-27-144hz",
"q": "gaming monitor"
},
{
"title": "TP-Link Wi-Fi 6 Router",
"price": 1999.0,
"shippingPrice": 29.9,
"url": "https://example.com/tplink-wifi6",
"q": "wifi router"
},
{
"title": "Razer Huntsman Mini Klavye",
"price": 3499.0,
"originalPrice": 3999.0,
"url": "https://example.com/huntsman-mini",
"q": "mechanical keyboard"
},
{
"title": "WD Elements 2TB Harici Disk",
"price": 2399.0,
"shippingPrice": 49.9,
"url": "https://example.com/wd-elements-2tb",
"q": "external hard drive"
},
{
"title": "Samsung T7 Shield 1TB SSD",
"price": 2799.0,
"originalPrice": 3299.0,
"shippingPrice": 0,
"url": "https://example.com/samsung-t7-shield",
"q": "portable ssd"
},
{
"title": "Xiaomi Mi Band 8",
"price": 1399.0,
"originalPrice": 1699.0,
"shippingPrice": 0,
"url": "https://example.com/mi-band-8",
"q": "smart band"
},
{
"title": "Philips Airfryer 6.2L",
"price": 5999.0,
"originalPrice": 7499.0,
"url": "https://example.com/philips-airfryer",
"q": "air fryer"
},
{
"title": "Dyson V12 Detect Slim",
"price": 21999.0,
"originalPrice": 25999.0,
"shippingPrice": 0,
"url": "https://example.com/dyson-v12",
"q": "vacuum cleaner"
},
{
"title": "Nespresso Vertuo Kahve Makinesi",
"price": 6999.0,
"originalPrice": 8499.0,
"shippingPrice": 0,
"url": "https://example.com/nespresso-vertuo",
"q": "coffee machine"
},
{
"title": "Nintendo Switch OLED 64GB",
"price": 11999.0,
"originalPrice": 13999.0,
"shippingPrice": 0,
"url": "https://example.com/nintendo-switch-oled",
"q": "game console"
},
{
"title": "PlayStation 5 DualSense Controller",
"price": 2499.0,
"originalPrice": 2999.0,
"url": "https://example.com/ps5-dualsense",
"q": "game controller"
},
{
"title": "Xbox Game Pass 3 Aylık Üyelik",
"price": 699.0,
"originalPrice": 899.0,
"url": "https://example.com/xbox-game-pass-3m",
"q": "subscription"
},
{
"title": "JBL Flip 6 Bluetooth Hoparlör",
"price": 3499.0,
"originalPrice": 4299.0,
"shippingPrice": 0,
"url": "https://example.com/jbl-flip-6",
"q": "bluetooth speaker"
},
{
"title": "ASUS TUF Gaming RTX 4060 8GB",
"price": 16999.0,
"originalPrice": 19999.0,
"shippingPrice": 0,
"url": "https://example.com/rtx-4060-asus-tuf",
"q": "graphics card"
},
{
"title": "Corsair Vengeance 32GB (2x16) DDR5 6000",
"price": 3999.0,
"originalPrice": 4999.0,
"url": "https://example.com/corsair-ddr5-32gb-6000",
"q": "ram memory"
},
{
"title": "Samsung 55\" 4K Smart TV (Crystal UHD)",
"price": 18999.0,
"originalPrice": 22999.0,
"shippingPrice": 0,
"url": "https://example.com/samsung-55-4k-tv",
"q": "television"
},
{
"title": "LG 27\" 4K IPS Monitör",
"price": 10999.0,
"originalPrice": 12999.0,
"shippingPrice": 0,
"url": "https://example.com/lg-27-4k-ips",
"q": "4k monitor"
},
{
"title": "Roborock S8 Robot Süpürge",
"price": 24999.0,
"originalPrice": 29999.0,
"shippingPrice": 0,
"url": "https://example.com/roborock-s8",
"q": "robot vacuum"
},
{
"title": "Tefal Ultragliss Buharlı Ütü",
"price": 1799.0,
"originalPrice": 2199.0,
"shippingPrice": 29.9,
"url": "https://example.com/tefal-ultragliss-iron",
"q": "steam iron"
},
{
"title": "Brita Marella XL Su Arıtma Sürahisi",
"price": 899.0,
"originalPrice": 1099.0,
"shippingPrice": 19.9,
"url": "https://example.com/brita-marella-xl",
"q": "water filter"
},
{
"title": "IKEA Markus Ofis Koltuğu",
"price": 4999.0,
"shippingPrice": 59.9,
"url": "https://example.com/ikea-markus",
"q": "office chair"
},
{
"title": "Xiaomi Redmi Note 13 256GB",
"price": 9999.0,
"originalPrice": 11999.0,
"shippingPrice": 0,
"url": "https://example.com/redmi-note-13-256",
"q": "smartphone"
},
{
"title": "Garmin Forerunner 255",
"price": 8999.0,
"originalPrice": 10499.0,
"shippingPrice": 0,
"url": "https://example.com/garmin-fr-255",
"q": "sports watch"
},
{
"title": "Philips Hue Starter Kit (3 Ampul + Bridge)",
"price": 4499.0,
"originalPrice": 5499.0,
"shippingPrice": 0,
"url": "https://example.com/philips-hue-starter",
"q": "smart lights"
}
]

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "description" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Deal" ADD COLUMN "originalPrice" DOUBLE PRECISION,
ADD COLUMN "percentOff" DOUBLE PRECISION,
ADD COLUMN "shippingPrice" DOUBLE PRECISION;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Seller" ADD COLUMN "sellerLogo" TEXT NOT NULL DEFAULT '';

View File

@ -0,0 +1,27 @@
-- AlterTable
ALTER TABLE "Comment" ADD COLUMN "likeCount" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "CommentLike" (
"id" SERIAL NOT NULL,
"commentId" INTEGER NOT NULL,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CommentLike_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "CommentLike_commentId_idx" ON "CommentLike"("commentId");
-- CreateIndex
CREATE INDEX "CommentLike_userId_idx" ON "CommentLike"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "CommentLike_commentId_userId_key" ON "CommentLike"("commentId", "userId");
-- AddForeignKey
ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CommentLike" ADD CONSTRAINT "CommentLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -36,6 +36,7 @@ model User {
dealNotices DealNotice[] @relation("UserDealNotices")
refreshTokens RefreshToken[] // <-- bunu ekle
commentLikes CommentLike[]
}
model RefreshToken {
@ -86,7 +87,6 @@ model SellerDomain {
domain String @unique
sellerId Int
seller Seller @relation(fields: [sellerId], references: [id])
createdAt DateTime @default(now())
createdById Int
createdBy User @relation(fields: [createdById], references: [id])
@ -96,6 +96,7 @@ model Seller {
id Int @id @default(autoincrement())
name String @unique
url String @default("")
sellerLogo String @default("")
isActive Boolean @default(true)
createdAt DateTime @default(now())
createdById Int
@ -113,7 +114,7 @@ model Category {
id Int @id @default(autoincrement())
name String
slug String @unique
description String @default("")
parentId Int?
parent Category? @relation("CategoryParent", fields: [parentId], references: [id])
children Category[] @relation("CategoryParent")
@ -157,14 +158,16 @@ model Deal {
description String?
url String?
price Float?
originalPrice Float?
shippingPrice Float?
percentOff Float?
userId Int
score Int @default(0)
commentCount Int @default(0)
status DealStatus @default(PENDING)
saletype SaleType @default(ONLINE)
affiliateType AffiliateType @default(NON_AFFILIATE)
sellerId Int?
customSeller String?
@ -267,6 +270,7 @@ model Comment {
dealId Int
parentId Int?
likeCount Int @default(0)
deletedAt DateTime?
@ -275,6 +279,7 @@ model Comment {
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
replies Comment[] @relation("CommentReplies")
likes CommentLike[]
@@index([dealId, createdAt])
@@index([parentId, createdAt])
@ -282,6 +287,20 @@ model Comment {
@@index([deletedAt])
}
model CommentLike {
id Int @id @default(autoincrement())
commentId Int
userId Int
createdAt DateTime @default(now())
comment Comment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([commentId, userId])
@@index([commentId])
@@index([userId])
}
enum DealAiIssueType {
NONE
PROFANITY
@ -308,4 +327,4 @@ model DealAiReview {
updatedAt DateTime @updatedAt
@@index([needsReview, hasIssue, updatedAt])
}
}

View File

@ -1,6 +1,5 @@
// prisma/seed.js
const { PrismaClient, DealStatus, SaleType, AffiliateType } = require("@prisma/client")
const bcrypt = require("bcryptjs")
const fs = require("fs")
const path = require("path")
@ -27,6 +26,12 @@ function normalizeSlug(s) {
return String(s ?? "").trim().toLowerCase()
}
function toNumberOrNull(v) {
if (v === null || v === undefined || v === "") return null
const n = Number(v)
return Number.isFinite(n) ? n : null
}
async function upsertTagBySlug(slug, name) {
const s = normalizeSlug(slug)
return prisma.tag.upsert({
@ -66,6 +71,7 @@ function loadCategoriesJson(filePath) {
id: Number(c.id),
name: String(c.name ?? "").trim(),
slug: normalizeSlug(c.slug),
description: c.description,
parentId: c.parentId === null || c.parentId === undefined ? null : Number(c.parentId),
}))
@ -102,11 +108,13 @@ async function seedCategoriesFromJson(categoriesFilePath) {
update: {
name: c.name,
slug: c.slug,
description: c.description,
},
create: {
id: c.id,
name: c.name,
slug: c.slug,
description: c.description,
parentId: null,
},
})
@ -135,56 +143,110 @@ async function seedCategoriesFromJson(categoriesFilePath) {
return { count: categories.length }
}
// 30 deal seed + her deal'a 3 foto + score 0-200 + tarih dağılımı:
// - %70: son 5 gün
// - %30: 10 gün önce civarı (9-11 gün arası)
async function seedDeals30({ userId, sellerId, categoryId }) {
const baseItems = [
{ title: "Samsung 990 PRO 1TB NVMe SSD", price: 3299.99, url: "https://example.com/samsung-990pro-1tb", q: "nvme ssd" },
{ title: "Logitech MX Master 3S Mouse", price: 2499.9, url: "https://example.com/mx-master-3s", q: "wireless mouse" },
{ title: "Sony WH-1000XM5 Kulaklık", price: 9999.0, url: "https://example.com/sony-xm5", q: "headphones" },
{ title: "Apple AirPods Pro 2", price: 8499.0, url: "https://example.com/airpods-pro-2", q: "earbuds" },
{ title: "Anker 65W GaN Şarj Aleti", price: 899.0, url: "https://example.com/anker-65w-gan", q: "charger" },
{ title: "Kindle Paperwhite 16GB", price: 5199.0, url: "https://example.com/kindle-paperwhite", q: "ebook reader" },
{ title: 'Dell 27" 144Hz Monitör', price: 7999.0, url: "https://example.com/dell-27-144hz", q: "gaming monitor" },
{ title: "TP-Link Wi-Fi 6 Router", price: 1999.0, url: "https://example.com/tplink-wifi6", q: "wifi router" },
{ title: "Razer Huntsman Mini Klavye", price: 3499.0, url: "https://example.com/huntsman-mini", q: "mechanical keyboard" },
{ title: "WD Elements 2TB Harici Disk", price: 2399.0, url: "https://example.com/wd-elements-2tb", q: "external hard drive" },
{ title: "Samsung T7 Shield 1TB SSD", price: 2799.0, url: "https://example.com/samsung-t7-shield", q: "portable ssd" },
{ title: "Xiaomi Mi Band 8", price: 1399.0, url: "https://example.com/mi-band-8", q: "smart band" },
{ title: "Philips Airfryer 6.2L", price: 5999.0, url: "https://example.com/philips-airfryer", q: "air fryer" },
{ title: "Dyson V12 Detect Slim", price: 21999.0, url: "https://example.com/dyson-v12", q: "vacuum cleaner" },
{ title: "Nespresso Vertuo Kahve Makinesi", price: 6999.0, url: "https://example.com/nespresso-vertuo", q: "coffee machine" },
]
function loadDealsJson(filePath) {
const raw = fs.readFileSync(filePath, "utf-8")
const arr = JSON.parse(raw)
// 30'a tamamlamak için ikinci bir set üret (title/url benzersiz olsun)
if (!Array.isArray(arr)) throw new Error("deals.json array olmalı")
const items = arr.map((d, idx) => {
const title = String(d.title ?? "").trim()
const url = String(d.url ?? "").trim()
const q = String(d.q ?? "").trim()
const price = toNumberOrNull(d.price)
const originalPrice = toNumberOrNull(d.originalPrice)
const shippingPrice = toNumberOrNull(d.shippingPrice)
if (!title) throw new Error(`deals.json: title boş (index=${idx})`)
if (!url) throw new Error(`deals.json: url boş (index=${idx})`)
if (price === null) throw new Error(`deals.json: price invalid (index=${idx})`)
// Mantık: originalPrice varsa price'dan küçük olamaz
if (originalPrice !== null && originalPrice < price) {
throw new Error(`deals.json: originalPrice < price (index=${idx})`)
}
return {
title,
url,
q,
price,
originalPrice,
shippingPrice,
}
})
// url unique olsun (seed idempotent)
const urlSet = new Set()
for (const it of items) {
if (urlSet.has(it.url)) throw new Error(`deals.json duplicate url: ${it.url}`)
urlSet.add(it.url)
}
return items
}
// deals.jsondan seed + her deala 3 foto + score 0-200 + tarih dağılımı:
// - %70: son 5 gün
// - %30: 9-11 gün önce
async function seedDealsFromJson({ userId, sellerId, categoryId, dealsFilePath }) {
const baseItems = loadDealsJson(dealsFilePath)
// 30 adet olacak şekilde çoğalt (title/url benzersizleşsin)
const items = []
for (let i = 0; i < 30; i++) {
const base = baseItems[i % baseItems.length]
const n = i + 1
// price'ı hafif oynat (base price üzerinden)
const price = Number((base.price * (0.9 + randInt(0, 30) / 100)).toFixed(2))
// originalPrice varsa, yeni price'a göre ölçekle (mantık korunur)
let originalPrice = null
if (base.originalPrice !== null && base.originalPrice !== undefined) {
const ratio = base.originalPrice / base.price // >= 1 olmalı
originalPrice = Number((price * ratio).toFixed(2))
if (originalPrice < price) originalPrice = Number((price * 1.05).toFixed(2))
}
// shippingPrice varsa bazen aynen, bazen 0/ufak varyasyon (ama null değilse)
let shippingPrice = null
if (base.shippingPrice !== null && base.shippingPrice !== undefined) {
// 70% aynı, 30% küçük oynat
if (Math.random() < 0.7) {
shippingPrice = Number(base.shippingPrice)
} else {
const candidates = [0, 19.9, 29.9, 39.9, 49.9, 59.9]
shippingPrice = candidates[randInt(0, candidates.length - 1)]
}
}
items.push({
title: `${base.title} #${n}`,
price: Number((base.price * (0.9 + (randInt(0, 30) / 100))).toFixed(2)),
url: `${base.url}?seed=${n}`,
q: base.q,
price,
originalPrice,
shippingPrice,
url: `${base.url}${base.url.includes("?") ? "&" : "?"}seed=${n}`,
q: base.q || "product",
})
}
for (let i = 0; i < items.length; i++) {
const it = items[i]
// %30'u 9-11 gün önce, %70'i son 5 gün
const older = Math.random() < 0.3
const createdAt = older
? new Date(Date.now() - randInt(9, 11) * 24 * 60 * 60 * 1000 - randInt(0, 12) * 60 * 60 * 1000)
: randomDateWithinLastDays(5)
// Not: modelinde score yoksa score satırını sil
const dealData = {
title: it.title,
description: "Seed test deal açıklaması (otomatik üretim).",
url: it.url,
price: it.price,
originalPrice: it.originalPrice ?? null,
shippingPrice: it.shippingPrice ?? null,
status: DealStatus.ACTIVE,
saletype: SaleType.ONLINE,
affiliateType: AffiliateType.NON_AFFILIATE,
@ -249,6 +311,7 @@ async function main() {
create: {
name: "Amazon",
url: "https://www.amazon.com.tr",
sellerLogo:"https://1000logos.net/wp-content/uploads/2016/10/Amazon-logo-meaning.jpg",
isActive: true,
createdById: admin.id,
},
@ -269,7 +332,7 @@ async function main() {
}
// ---------- CATEGORIES (FROM JSON) ----------
const categoriesFilePath = path.join(__dirname, "", "categories.json")
const categoriesFilePath = path.join(__dirname, "categories.json")
const { count } = await seedCategoriesFromJson(categoriesFilePath)
const catSSD = await prisma.category.findUnique({
@ -294,6 +357,8 @@ async function main() {
description: "Test deal açıklaması",
url: dealUrl,
price: 1299.99,
originalPrice: 1499.99, // örnek
shippingPrice: 0, // örnek
status: DealStatus.ACTIVE,
saletype: SaleType.ONLINE,
affiliateType: AffiliateType.NON_AFFILIATE,
@ -321,11 +386,13 @@ async function main() {
],
})
// ✅ ---------- 30 DEAL ÜRET ----------
await seedDeals30({
// ✅ ---------- deals.jsondan 30 DEAL ÜRET ----------
const dealsFilePath = path.join(__dirname, "deals.json")
await seedDealsFromJson({
userId: user.id,
sellerId: amazon.id,
categoryId: catSSD?.id ?? 0,
dealsFilePath,
})
// ---------- VOTE ----------
@ -347,7 +414,7 @@ async function main() {
}
console.log(`✅ Seed tamamlandı (categories.json yüklendi: ${count} kategori)`)
console.log("✅ 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi")
console.log("✅ deals.json baz alınarak 30 adet test deal + 3'er görsel + score(0-200) + tarih dağılımı eklendi/güncellendi")
}
main()

View File

@ -1,6 +1,7 @@
const express = require("express");
const categoryService = require("../services/category.service"); // Kategori servisi
const router = express.Router();
const optionalAuth = require("../middleware/optionalAuth")
const { mapCategoryToCategoryDetailsResponse }=require("../adapters/responses/categoryDetails.adapter")
const { mapDealToDealDetailResponse } = require("../adapters/responses/dealDetail.adapter")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
@ -22,7 +23,10 @@ router.get("/:slug", async (req, res) => {
});
router.get("/:slug/deals", async (req, res) => {
const buildViewer = (req) =>
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
router.get("/:slug/deals", optionalAuth, async (req, res) => {
const { slug } = req.params;
const { page = 1, limit = 10, ...filters } = req.query;
@ -33,7 +37,12 @@ router.get("/:slug/deals", async (req, res) => {
}
// Kategorinin fırsatlarını alıyoruz
const deals = await categoryService.getDealsByCategoryId(category.id, page, limit, filters);
const payload = await categoryService.getDealsByCategoryId(category.id, {
page,
limit,
filters,
viewer: buildViewer(req),
});
const response = mapPaginatedDealsToDealCardResponse(payload)

View File

@ -1,5 +1,6 @@
const express = require("express")
const requireAuth = require("../middleware/requireAuth.js")
const optionalAuth = require("../middleware/optionalAuth")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
const { createComment, deleteComment } = require("../services/comment.service")
@ -12,13 +13,28 @@ const { comments } = endpoints
router.get(
"/:dealId",
optionalAuth,
validate(comments.commentListRequestSchema, "params", "validatedDealId"),
async (req, res) => {
try {
const { dealId } = req.validatedDealId
const fetched = await commentService.getCommentsByDealId(dealId)
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(fetched)
res.json(comments.commentListResponseSchema.parse(mapped))
const { parentId, page, limit, sort } = req.query
const payload = await commentService.getCommentsByDealId(dealId, {
parentId,
page,
limit,
sort,
viewer: req.auth ? { userId: req.auth.userId } : null,
})
const mapped = dealCommentAdapter.mapCommentsToDealCommentResponse(payload.results)
res.json(
comments.commentListResponseSchema.parse({
page: payload.page,
total: payload.total,
totalPages: payload.totalPages,
results: mapped,
})
)
} catch (err) {
res.status(400).json({ error: err.message })
}

View File

@ -0,0 +1,23 @@
const express = require("express")
const requireAuth = require("../middleware/requireAuth")
const { setCommentLike } = require("../services/commentLike.service")
const router = express.Router()
// Body: { commentId: number, like: boolean | 0 | 1 }
router.post("/", requireAuth, async (req, res) => {
try {
const { commentId, like } = req.body || {}
const result = await setCommentLike({ commentId, userId: req.auth.userId, like })
res.json({
commentId: Number(commentId),
likeCount: result.likeCount,
liked: result.liked,
delta: result.delta,
})
} catch (err) {
res.status(400).json({ error: err.message || "Like işlemi başarısız" })
}
})
module.exports = router

View File

@ -34,6 +34,7 @@ function createListHandler(preset) {
page,
limit,
viewer,
filters: req.query,
})
const response = deals.dealsListResponseSchema.parse(
@ -76,6 +77,7 @@ router.get(
limit,
targetUserId: targetUser.id,
viewer,
filters: req.query,
})
const response = deals.dealsListResponseSchema.parse(
@ -120,6 +122,7 @@ router.get(
page,
limit,
viewer: buildViewer(req),
filters: req.query,
})
const response = deals.dealsListResponseSchema.parse(
@ -153,6 +156,7 @@ router.get("/top", optionalAuth, async (req, res) => {
page: 1,
limit,
viewer,
filters: req.query,
})
const response = deals.dealsListResponseSchema.parse(
@ -173,12 +177,13 @@ router.get("/top", optionalAuth, async (req, res) => {
router.get(
"/:id",
optionalAuth,
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
try {
const { id } = req.validatedDealId
const deal = await getDealById(id)
const deal = await getDealById(id, buildViewer(req))
if (!deal) return res.status(404).json({ error: "Deal bulunamadi" })
const mapped = mapDealToDealDetailResponse(deal)

104
routes/mod.routes.js Normal file
View File

@ -0,0 +1,104 @@
const express = require("express")
const router = express.Router()
const requireAuth = require("../middleware/requireAuth")
const requireRole = require("../middleware/requireRole")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
const { getPendingDeals, approveDeal, rejectDeal, expireDeal, unexpireDeal } = require("../services/mod.service")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
const { deals } = endpoints
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
const buildViewer = (req) =>
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
router.get("/deals/pending", requireAuth, requireRole("MOD"), listQueryValidator, async (req, res) => {
try {
const { q, page, limit } = req.validatedDealListQuery
const payload = await getPendingDeals({
page,
limit,
filters: { ...req.query, q },
viewer: buildViewer(req),
})
const response = mapPaginatedDealsToDealCardResponse(payload)
res.json(response.results)
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
})
router.post(
"/deals/:id/approve",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
try {
const { id } = req.validatedDealId
const updated = await approveDeal(id)
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
router.post(
"/deals/:id/reject",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
try {
const { id } = req.validatedDealId
const updated = await rejectDeal(id)
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
router.post(
"/deals/:id/expire",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
try {
const { id } = req.validatedDealId
const updated = await expireDeal(id)
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
router.post(
"/deals/:id/unexpire",
requireAuth,
requireRole("MOD"),
validate(deals.dealDetailRequestSchema, "params", "validatedDealId"),
async (req, res) => {
try {
const { id } = req.validatedDealId
const updated = await unexpireDeal(id)
res.json({ id: updated.id, status: updated.status })
} catch (err) {
const status = err.statusCode || 500
res.status(status).json({ error: err.message || "Sunucu hatasi" })
}
}
)
module.exports = router

View File

@ -2,10 +2,20 @@ const express = require("express")
const router = express.Router()
const requireAuth = require("../middleware/requireAuth")
const optionalAuth = require("../middleware/optionalAuth")
const { validate } = require("../middleware/validate.middleware")
const { endpoints } = require("@shared/contracts")
const { findSellerFromLink } = require("../services/seller.service")
const { getSellerByName, getDealsBySellerName } = require("../services/seller.service")
const { findSellerFromLink } = require("../services/sellerLookup.service")
const { mapSellerToSellerDetailsResponse } = require("../adapters/responses/sellerDetails.adapter")
const { mapPaginatedDealsToDealCardResponse } = require("../adapters/responses/dealCard.adapter")
const { seller } = endpoints
const { seller, deals } = endpoints
const buildViewer = (req) =>
req.auth ? { userId: req.auth.userId, role: req.auth.role } : null
const listQueryValidator = validate(deals.dealsListRequestSchema, "query", "validatedDealListQuery")
router.post("/from-link", requireAuth, async (req, res) => {
try {
@ -32,4 +42,37 @@ router.post("/from-link", requireAuth, async (req, res) => {
}
})
router.get("/:sellerName", async (req, res) => {
try {
const sellerName = req.params.sellerName
const sellerInfo = await getSellerByName(sellerName)
if (!sellerInfo) return res.status(404).json({ error: "Seller bulunamadi" })
res.json(mapSellerToSellerDetailsResponse(sellerInfo))
} catch (e) {
const status = e.statusCode || 500
res.status(status).json({ error: e.message || "Sunucu hatasi" })
}
})
router.get("/:sellerName/deals", optionalAuth, listQueryValidator, async (req, res) => {
try {
const sellerName = req.params.sellerName
const { q, page, limit } = req.validatedDealListQuery
const { payload } = await getDealsBySellerName(sellerName, {
page,
limit,
filters: req.query,
viewer: buildViewer(req),
})
const response = mapPaginatedDealsToDealCardResponse(payload)
res.json(response.results)
} catch (e) {
const status = e.statusCode || 500
res.status(status).json({ error: e.message || "Sunucu hatasi" })
}
})
module.exports = router

View File

@ -13,15 +13,27 @@ const accountSettingsRoutes = require("./routes/accountSettings.routes");
const userRoutes = require("./routes/user.routes");
const sellerRoutes = require("./routes/seller.routes");
const voteRoutes = require("./routes/vote.routes");
const commentLikeRoutes = require("./routes/commentLike.routes");
const categoryRoutes =require("./routes/category.routes")
const modRoutes = require("./routes/mod.routes")
const app = express();
// CORS middleware'ı ile dışardan gelen istekleri kontrol et
app.use(cors({
origin: "http://localhost:5173", // Frontend adresi
credentials: true, // Cookies'in paylaşıma izin verilmesi
}));
const allowedOrigins = new Set([
"http://192.168.1.205:3001",
"http://localhost:3001",
]);
app.use(
cors({
origin(origin, cb) {
if (!origin) return cb(null, true);
if (allowedOrigins.has(origin)) return cb(null, true);
return cb(new Error("CORS_NOT_ALLOWED"));
},
credentials: true, // Cookies'in paylaşıma izin verilmesi
})
);
// JSON, URL encoded ve cookies'leri parse etme
app.use(express.json()); // JSON verisi almak için
app.use(express.urlencoded({ extended: true })); // URL encoded veriler için
@ -37,6 +49,8 @@ app.use("/api/account", accountSettingsRoutes); // Account settings işlemleri
app.use("/api/user", userRoutes); // Kullanıcı işlemleri
app.use("/api/seller", sellerRoutes); // Seller işlemleri
app.use("/api/vote", voteRoutes); // Vote işlemleri
app.use("/api/comment-likes", commentLikeRoutes); // Comment like işlemleri
app.use("/api/category", categoryRoutes);
app.use("/api/mod", modRoutes);
// Sunucuyu dinlemeye başla
app.listen(3000, () => console.log("Server running on http://localhost:3000"));

View File

@ -1,69 +1,42 @@
const categoryDb = require("../db/category.db"); // DB işlemleri için category.db.js'i import ediyoruz
const dealDb = require("../db/deal.db");
/**
* Kategoriyi slug'a göre bul
* Bu fonksiyon, verilen slug'a sahip kategori bilgilerini döndürür
*/
const categoryDb = require("../db/category.db")
const dealService = require("./deal.service")
async function findCategoryBySlug(slug) {
try {
// Kategori bilgisini slug'a göre buluyoruz
const category = await categoryDb.findCategoryBySlug(slug, {
include: {
children: true, // Alt kategorileri de dahil edebiliriz
deals: true, // Kategorinin deals ilişkisini alıyoruz
},
});
if (!category) {
throw new Error("Kategori bulunamadı");
}
// Kategori breadcrumb'ını alıyoruz
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id);
return { category, breadcrumb }; // Kategori ve breadcrumb'ı döndürüyoruz
} catch (err) {
throw new Error(`Kategori bulma hatası: ${err.message}`);
const normalizedSlug = String(slug || "").trim()
if (!normalizedSlug) {
throw new Error("INVALID_SLUG")
}
const category = await categoryDb.findCategoryBySlug(normalizedSlug)
if (!category) {
throw new Error("CATEGORY_NOT_FOUND")
}
const breadcrumb = await categoryDb.getCategoryBreadcrumb(category.id)
return { category, breadcrumb }
}
async function getDealsByCategoryId(categoryId, page = 1, limit = 10, filters = {}) {
try {
// Sayfalama ve filtreleme için gerekli ayarlamaları yapıyoruz
const take = Math.min(Math.max(Number(limit) || 10, 1), 100); // Limit ve sayfa sayısını hesaplıyoruz
const skip = (Math.max(Number(page) || 1, 1) - 1) * take; // Sayfa başlangıcı
// Kategorinin fırsatlarını almak için veritabanında sorgu yapıyoruz
const where = {
categoryId: categoryId,
...(filters.q && {
OR: [
{ title: { contains: filters.q, mode: 'insensitive' } },
{ description: { contains: filters.q, mode: 'insensitive' } },
],
}),
...(filters.status && { status: filters.status }),
...(filters.price && { price: { gte: filters.price } }), // Fiyat filtresi
// Diğer filtreler de buraya eklenebilir
};
// `getDealCards` fonksiyonunu çağırıyoruz ve sayfalama, filtreleme işlemlerini geçiyoruz
const deals = await dealDb.getDealCards({
where,
skip,
take, // Sayfalama işlemi için take parametresini gönderiyoruz
});
return deals; // Fırsatları döndürüyoruz
} catch (err) {
throw new Error(`Kategoriye ait fırsatlar alınırken hata: ${err.message}`);
async function getDealsByCategoryId(categoryId, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) {
const normalizedId = Number(categoryId)
if (!Number.isInteger(normalizedId) || normalizedId <= 0) {
throw new Error("INVALID_CATEGORY_ID")
}
const categoryIds = await categoryDb.getCategoryDescendantIds(normalizedId)
return dealService.getDeals({
preset: "NEW",
q: filters?.q,
page,
limit,
viewer,
scope,
baseWhere: { categoryId: { in: categoryIds } },
filters,
})
}
module.exports = {
findCategoryBySlug,
getDealsByCategoryId,
};
}

View File

@ -8,14 +8,87 @@ function assertPositiveInt(v, name = "id") {
return n
}
async function getCommentsByDealId(dealId) {
const DEFAULT_LIMIT = 20
const MAX_LIMIT = 50
const MAX_SKIP = 5000
function clampPagination({ page, limit }) {
const rawPage = Number(page)
const rawLimit = Number(limit)
const normalizedPage = Number.isFinite(rawPage) ? Math.max(1, Math.floor(rawPage)) : 1
let normalizedLimit = Number.isFinite(rawLimit) ? Math.max(1, Math.floor(rawLimit)) : DEFAULT_LIMIT
normalizedLimit = Math.min(MAX_LIMIT, normalizedLimit)
const skip = (normalizedPage - 1) * normalizedLimit
if (skip > MAX_SKIP) throw new Error("PAGE_TOO_DEEP")
return { page: normalizedPage, limit: normalizedLimit, skip }
}
function parseParentId(value) {
if (value === undefined) return null
if (value === null) return null
if (value === "" || value === "null") return null
const pid = Number(value)
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Geçersiz parentId.")
return pid
}
function normalizeSort(value) {
const normalized = String(value || "new").trim().toLowerCase()
if (["top", "best", "liked"].includes(normalized)) return "TOP"
return "NEW"
}
async function getCommentsByDealId(dealId, { parentId, page, limit, sort, viewer } = {}) {
const id = Number(dealId)
const deal = await dealDB.findDeal({ id })
if (!deal) throw new Error("Deal bulunamadı.")
const include = { user: { select: { id:true,username: true, avatarUrl: true } } }
return commentDB.findComments({ dealId: id }, { include })
const include = {
user: { select: { id: true, username: true, avatarUrl: true } },
_count: { select: { replies: true } },
}
const pagination = clampPagination({ page, limit })
const parsedParentId = parseParentId(parentId)
const sortMode = normalizeSort(sort)
const orderBy =
sortMode === "TOP"
? [{ likeCount: "desc" }, { createdAt: "desc" }]
: [{ createdAt: "desc" }]
const where = { dealId: id, parentId: parsedParentId }
const [results, total] = await Promise.all([
commentDB.findComments(where, {
include,
orderBy,
skip: pagination.skip,
take: pagination.limit,
}),
commentDB.countComments(where),
])
let likedIds = new Set()
if (viewer?.userId && results.length > 0) {
const commentLikeDb = require("../db/commentLike.db")
const likes = await commentLikeDb.findLikesByUserAndCommentIds(
viewer.userId,
results.map((c) => c.id)
)
likedIds = new Set(likes.map((l) => l.commentId))
}
const enriched = results.map((comment) => ({
...comment,
myLike: likedIds.has(comment.id),
}))
return {
page: pagination.page,
total,
totalPages: Math.ceil(total / pagination.limit),
results: enriched,
}
}
async function createComment({ dealId, userId, text, parentId = null }) {
@ -27,8 +100,11 @@ async function createComment({ dealId, userId, text, parentId = null }) {
const include = { user: { select: { id: true, username: true, avatarUrl: true } } }
return prisma.$transaction(async (tx) => {
const deal = await dealDB.findDeal({ id: dealId }, {}, tx)
const deal = await dealDB.findDeal({ id: dealId }, { select: { id: true, status: true } }, tx)
if (!deal) throw new Error("Deal bulunamadı.")
if (deal.status !== "ACTIVE" && deal.status !== "EXPIRED") {
throw new Error("Bu deal için yorum açılamaz.")
}
// ✅ Reply ise parent doğrula
let parent = null
@ -66,15 +142,26 @@ async function createComment({ dealId, userId, text, parentId = null }) {
async function deleteComment(commentId, userId) {
const comments = await commentDB.findComments(
const comment = await commentDB.findComment(
{ id: commentId },
{ select: { userId: true } }
{ select: { userId: true, dealId: true, deletedAt: true } }
)
if (!comments || comments.length === 0) throw new Error("Yorum bulunamadı.")
if (comments[0].userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
if (!comment || comment.deletedAt) throw new Error("Yorum bulunamadı.")
if (comment.userId !== userId) throw new Error("Bu yorumu silme yetkin yok.")
await prisma.$transaction(async (tx) => {
const result = await commentDB.softDeleteComment({ id: commentId, deletedAt: null }, tx)
if (result.count > 0) {
await dealDB.updateDeal(
{ id: comment.dealId },
{ commentCount: { decrement: 1 } },
{},
tx
)
}
})
await commentDB.deleteComment({ id: commentId })
return { message: "Yorum silindi." }
}

View File

@ -0,0 +1,30 @@
const commentLikeDb = require("../db/commentLike.db")
const commentDb = require("../db/comment.db")
function parseLike(value) {
if (typeof value === "boolean") return value
if (typeof value === "number") return value === 1
if (typeof value === "string") {
const normalized = value.trim().toLowerCase()
if (["true", "1", "yes"].includes(normalized)) return true
if (["false", "0", "no"].includes(normalized)) return false
}
return null
}
async function setCommentLike({ commentId, userId, like }) {
const cid = Number(commentId)
if (!Number.isInteger(cid) || cid <= 0) throw new Error("Geçersiz commentId.")
const shouldLike = parseLike(like)
if (shouldLike === null) throw new Error("Geçersiz like.")
// Ensure comment exists (and not deleted)
const existing = await commentDb.findComment({ id: cid }, { select: { id: true } })
if (!existing) throw new Error("Yorum bulunamadı.")
return commentLikeDb.setCommentLike({ commentId: cid, userId, like: shouldLike })
}
module.exports = {
setCommentLike,
}

View File

@ -1,6 +1,6 @@
// services/deal.service.js
const dealDB = require("../db/deal.db")
const { findSellerFromLink } = require("./seller.service")
const { findSellerFromLink } = require("./sellerLookup.service")
const { makeDetailWebp, makeThumbWebp } = require("../utils/processImage")
const { v4: uuidv4 } = require("uuid")
const { uploadImage } = require("./uploadImage.service")
@ -13,7 +13,22 @@ const MAX_LIMIT = 50
const MAX_SKIP = 5000
const MS_PER_DAY = 24 * 60 * 60 * 1000
const DEAL_LIST_INCLUDE = {
const DEAL_CARD_SELECT = {
id: true,
title: true,
description: true,
price: true,
originalPrice: true,
shippingPrice: true,
score: true,
commentCount: true,
url: true,
status: true,
saletype: true,
affiliateType: true,
createdAt: true,
updatedAt: true,
customSeller: true,
user: { select: { id: true, username: true, avatarUrl: true } },
seller: { select: { id: true, name: true, url: true } },
images: {
@ -23,6 +38,59 @@ const DEAL_LIST_INCLUDE = {
},
}
const DEAL_DETAIL_SELECT = {
id: true,
title: true,
description: true,
url: true,
price: true,
originalPrice: true,
shippingPrice: true,
score: true,
commentCount: true,
status: true,
saletype: true,
affiliateType: true,
createdAt: true,
updatedAt: true,
categoryId: true,
sellerId: true,
customSeller: true,
user: { select: { id: true, username: true, avatarUrl: true } },
seller: { select: { id: true, name: true, url: true } },
images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
notices: {
where: { isActive: true },
orderBy: { createdAt: "desc" },
take: 1,
select: {
id: true,
dealId: true,
title: true,
body: true,
severity: true,
isActive: true,
createdBy: true,
createdAt: true,
updatedAt: true,
},
},
_count: { select: { comments: true } },
}
const SIMILAR_DEAL_SELECT = {
id: true,
title: true,
price: true,
score: true,
createdAt: true,
categoryId: true,
sellerId: true,
customSeller: true,
seller: { select: { name: true } },
images: { take: 1, orderBy: { order: "asc" }, select: { imageUrl: true } },
}
function formatDateAsString(value) {
return value instanceof Date ? value.toISOString() : value ?? null
}
@ -54,6 +122,132 @@ function buildSearchClause(q) {
}
}
const DEAL_STATUSES = new Set(["PENDING", "ACTIVE", "EXPIRED", "REJECTED"])
const SALE_TYPES = new Set(["ONLINE", "OFFLINE", "CODE"])
const AFFILIATE_TYPES = new Set(["AFFILIATE", "NON_AFFILIATE", "USER_AFFILIATE"])
function normalizeListInput(value) {
if (Array.isArray(value)) {
return value.flatMap((item) => String(item).split(","))
}
if (value === undefined || value === null) return []
return String(value).split(",")
}
function parseInteger(value) {
if (value === undefined || value === null || value === "") return null
const num = Number(value)
return Number.isInteger(num) ? num : null
}
function parseNumber(value) {
if (value === undefined || value === null || value === "") return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
function parseBoolean(value) {
if (typeof value === "boolean") return value
if (typeof value === "number") return value === 1 ? true : value === 0 ? false : null
if (typeof value === "string") {
const normalized = value.trim().toLowerCase()
if (["true", "1", "yes"].includes(normalized)) return true
if (["false", "0", "no"].includes(normalized)) return false
}
return null
}
function parseDate(value) {
if (value === undefined || value === null || value === "") return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
}
function parseIdList(value) {
const items = normalizeListInput(value)
const ids = items
.map((item) => parseInteger(String(item).trim()))
.filter((item) => item && item > 0)
return ids.length ? Array.from(new Set(ids)) : null
}
function parseEnumList(value, allowedSet) {
const items = normalizeListInput(value)
const filtered = items
.map((item) => String(item).trim().toUpperCase())
.filter((item) => allowedSet.has(item))
return filtered.length ? Array.from(new Set(filtered)) : null
}
function buildFilterWhere(rawFilters = {}, { allowStatus = false } = {}) {
if (!rawFilters || typeof rawFilters !== "object") return null
const clauses = []
const categoryIds = parseIdList(rawFilters.categoryId ?? rawFilters.categoryIds)
if (categoryIds?.length) {
clauses.push({ categoryId: { in: categoryIds } })
}
const sellerIds = parseIdList(rawFilters.sellerId ?? rawFilters.sellerIds)
if (sellerIds?.length) {
clauses.push({ sellerId: { in: sellerIds } })
}
const saleTypes = parseEnumList(rawFilters.saleType, SALE_TYPES)
if (saleTypes?.length) {
clauses.push({ saletype: { in: saleTypes } })
}
const affiliateTypes = parseEnumList(rawFilters.affiliateType, AFFILIATE_TYPES)
if (affiliateTypes?.length) {
clauses.push({ affiliateType: { in: affiliateTypes } })
}
if (allowStatus) {
const statuses = parseEnumList(rawFilters.status, DEAL_STATUSES)
if (statuses?.length) {
clauses.push({ status: { in: statuses } })
}
}
const minPrice = parseNumber(rawFilters.minPrice ?? rawFilters.priceMin)
const maxPrice = parseNumber(rawFilters.maxPrice ?? rawFilters.priceMax)
if (minPrice !== null || maxPrice !== null) {
const price = {}
if (minPrice !== null) price.gte = minPrice
if (maxPrice !== null) price.lte = maxPrice
clauses.push({ price })
}
const minScore = parseNumber(rawFilters.minScore)
const maxScore = parseNumber(rawFilters.maxScore)
if (minScore !== null || maxScore !== null) {
const score = {}
if (minScore !== null) score.gte = minScore
if (maxScore !== null) score.lte = maxScore
clauses.push({ score })
}
const createdAfter = parseDate(rawFilters.createdAfter ?? rawFilters.from)
const createdBefore = parseDate(rawFilters.createdBefore ?? rawFilters.to)
if (createdAfter || createdBefore) {
const createdAt = {}
if (createdAfter) createdAt.gte = createdAfter
if (createdBefore) createdAt.lte = createdBefore
clauses.push({ createdAt })
}
const hasImage = parseBoolean(rawFilters.hasImage)
if (hasImage === true) {
clauses.push({ images: { some: {} } })
} else if (hasImage === false) {
clauses.push({ images: { none: {} } })
}
if (!clauses.length) return null
return clauses.length === 1 ? clauses[0] : { AND: clauses }
}
function buildPresetCriteria(preset, { viewer, targetUserId } = {}) {
const now = new Date()
switch (preset) {
@ -110,6 +304,9 @@ function buildPresetCriteria(preset, { viewer, targetUserId } = {}) {
orderBy: [{ score: "desc" }, { createdAt: "desc" }],
}
}
case "RAW": {
return { where: {}, orderBy: [{ createdAt: "desc" }] }
}
default: {
const err = new Error("INVALID_PRESET")
err.statusCode = 400
@ -155,14 +352,28 @@ function titleOverlapScore(aTitle, bTitle) {
* - seller?: { name }
* - images?: [{ imageUrl }]
*/
async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) {
const take = clamp(Number(limit) || 5, 1, 10)
async function buildSimilarDealsForDetail(targetDeal, { limit = 12 } = {}) {
const take = clamp(Number(limit) || 12, 1, 12)
// Bu 2 DB fonksiyonu: ACTIVE filter + images(take:1) + seller(name) getirmeli
const [byCategory, bySeller] = await Promise.all([
dealDB.findSimilarCandidatesByCategory(targetDeal.categoryId, targetDeal.id, { take: 80 }),
dealDB.findSimilarCandidates(
{
id: { not: Number(targetDeal.id) },
status: "ACTIVE",
categoryId: Number(targetDeal.categoryId),
},
{ take: 80, select: SIMILAR_DEAL_SELECT }
),
targetDeal.sellerId
? dealDB.findSimilarCandidatesBySeller(targetDeal.sellerId, targetDeal.id, { take: 30 })
? dealDB.findSimilarCandidates(
{
id: { not: Number(targetDeal.id) },
status: "ACTIVE",
sellerId: Number(targetDeal.sellerId),
},
{ take: 30, select: SIMILAR_DEAL_SELECT }
)
: Promise.resolve([]),
])
@ -210,14 +421,31 @@ async function buildSimilarDealsForDetail(targetDeal, { limit = 5 } = {}) {
}))
}
async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetUserId = null }) {
async function getDeals({
preset = "NEW",
q,
page,
limit,
viewer = null,
targetUserId = null,
filters = null,
baseWhere = null,
scope = "USER",
}) {
const pagination = clampPagination({ page, limit })
const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, { viewer, targetUserId })
const { where: presetWhere, orderBy: presetOrder } = buildPresetCriteria(preset, {
viewer,
targetUserId,
})
const searchClause = buildSearchClause(q)
const allowStatus = preset === "MY" || scope === "MOD"
const filterWhere = buildFilterWhere(filters, { allowStatus })
const clauses = []
if (presetWhere && Object.keys(presetWhere).length > 0) clauses.push(presetWhere)
if (baseWhere && Object.keys(baseWhere).length > 0) clauses.push(baseWhere)
if (searchClause) clauses.push(searchClause)
if (filterWhere) clauses.push(filterWhere)
const finalWhere = clauses.length === 0 ? {} : clauses.length === 1 ? clauses[0] : { AND: clauses }
const orderBy = presetOrder ?? [{ createdAt: "desc" }]
@ -227,7 +455,7 @@ async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetU
skip: pagination.skip,
take: pagination.limit,
orderBy,
include: DEAL_LIST_INCLUDE,
select: DEAL_CARD_SELECT,
}),
dealDB.countDeals(finalWhere),
])
@ -256,64 +484,41 @@ async function getDeals({ preset = "NEW", q, page, limit, viewer = null, targetU
}
}
async function getDealById(id) {
async function getDealById(id, viewer = null) {
const deal = await dealDB.findDeal(
{ id: Number(id) },
{
include: {
seller: { select: { id: true, name: true, url: true } },
user: { select: { id: true, username: true, avatarUrl: true } },
images: { orderBy: { order: "asc" }, select: { id: true, imageUrl: true, order: true } },
notices: {
where: { isActive: true },
orderBy: { createdAt: "desc" },
take: 1,
select: {
id: true,
dealId: true,
title: true,
body: true,
severity: true,
isActive: true,
createdBy: true,
createdAt: true,
updatedAt: true,
},
},
comments: {
orderBy: { createdAt: "desc" },
select: {
id: true,
text: true,
createdAt: true,
user: { select: { id: true, username: true, avatarUrl: true } },
},
},
_count: { select: { comments: true } },
},
select: DEAL_DETAIL_SELECT,
}
)
if (!deal) return null
const breadcrumb = await categoryDB.getCategoryBreadcrumb(deal.categoryId, {
includeUndefined: false,
})
const [breadcrumb, similarDeals, userStatsAgg] = await Promise.all([
categoryDB.getCategoryBreadcrumb(deal.categoryId, { includeUndefined: false }),
buildSimilarDealsForDetail(
{
id: deal.id,
title: deal.title,
categoryId: deal.categoryId,
sellerId: deal.sellerId ?? null,
},
{ limit: 12 }
),
deal.user?.id ? dealDB.aggregateDeals({ userId: deal.user.id }) : Promise.resolve(null),
])
const similarDeals = await buildSimilarDealsForDetail(
{
id: deal.id,
title: deal.title,
categoryId: deal.categoryId,
sellerId: deal.sellerId ?? null,
},
{ limit: 5 }
)
const userStats = {
totalLikes: userStatsAgg?._sum?.score ?? 0,
totalDeals: userStatsAgg?._count?._all ?? 0,
}
return {
...deal,
comments: [],
breadcrumb,
similarDeals,
userStats,
}
}

65
services/mod.service.js Normal file
View File

@ -0,0 +1,65 @@
const dealService = require("./deal.service")
const dealDB = require("../db/deal.db")
async function getPendingDeals({ page = 1, limit = 10, filters = {}, viewer = null } = {}) {
return dealService.getDeals({
preset: "RAW",
q: filters?.q,
page,
limit,
viewer,
scope: "MOD",
baseWhere: { status: "PENDING" },
filters,
})
}
async function updateDealStatus(dealId, nextStatus) {
const id = Number(dealId)
if (!Number.isInteger(id) || id <= 0) {
const err = new Error("INVALID_DEAL_ID")
err.statusCode = 400
throw err
}
const deal = await dealDB.findDeal({ id }, { select: { id: true, status: true } })
if (!deal) {
const err = new Error("DEAL_NOT_FOUND")
err.statusCode = 404
throw err
}
if (deal.status === nextStatus) return { id: deal.id, status: deal.status }
const updated = await dealDB.updateDeal(
{ id },
{ status: nextStatus },
{ select: { id: true, status: true } }
)
return updated
}
async function approveDeal(dealId) {
return updateDealStatus(dealId, "ACTIVE")
}
async function rejectDeal(dealId) {
return updateDealStatus(dealId, "REJECTED")
}
async function expireDeal(dealId) {
return updateDealStatus(dealId, "EXPIRED")
}
async function unexpireDeal(dealId) {
return updateDealStatus(dealId, "ACTIVE")
}
module.exports = {
getPendingDeals,
approveDeal,
rejectDeal,
expireDeal,
unexpireDeal,
}

View File

@ -1,38 +1,48 @@
// services/seller/sellerService.js
const { findSellerByDomain } = require("../db/seller.db")
const { findSeller } = require("../db/seller.db")
const dealService = require("./deal.service")
function normalizeDomain(hostname) {
return hostname.replace(/^www\./, "")
function normalizeSellerName(value) {
return String(value || "").trim()
}
async function findSellerFromLink(url) {
let hostname
try {
hostname = new URL(url).hostname
} catch {
return null
async function getSellerByName(name) {
const normalized = normalizeSellerName(name)
if (!normalized) {
const err = new Error("SELLER_NAME_REQUIRED")
err.statusCode = 400
throw err
}
const domain = normalizeDomain(hostname)
return findSeller(
{ name: { equals: normalized, mode: "insensitive" } },
{ select: { id: true, name: true, url: true, sellerLogo: true } }
)
}
const seller = await findSellerByDomain(domain)
if (seller) {
return seller
async function getDealsBySellerName(name, { page = 1, limit = 10, filters = {}, viewer = null, scope = "USER" } = {}) {
const seller = await getSellerByName(name)
if (!seller) {
const err = new Error("SELLER_NOT_FOUND")
err.statusCode = 404
throw err
}
const domainParts = domain.split(".")
for (let i = 1; i <= domainParts.length - 2; i += 1) {
const parentDomain = domainParts.slice(i).join(".")
const parentSeller = await findSellerByDomain(parentDomain)
if (parentSeller) {
return parentSeller
}
}
const payload = await dealService.getDeals({
preset: "NEW",
q: filters?.q,
page,
limit,
viewer,
scope,
baseWhere: { sellerId: seller.id, status: "ACTIVE" },
filters,
})
return null
return { seller, payload }
}
module.exports = {
findSellerFromLink,
getSellerByName,
getDealsBySellerName,
}

View File

@ -0,0 +1,37 @@
const { findSellerByDomain } = require("../db/seller.db")
function normalizeDomain(hostname) {
return hostname.replace(/^www\./, "")
}
async function findSellerFromLink(url) {
let hostname
try {
hostname = new URL(url).hostname
} catch {
return null
}
const domain = normalizeDomain(hostname)
const seller = await findSellerByDomain(domain)
if (seller) {
return seller
}
const domainParts = domain.split(".")
for (let i = 1; i <= domainParts.length - 2; i += 1) {
const parentDomain = domainParts.slice(i).join(".")
const parentSeller = await findSellerByDomain(parentDomain)
if (parentSeller) {
return parentSeller
}
}
return null
}
module.exports = {
findSellerFromLink,
}