From 84e1ef9ee6cf16af113162fc3e195071cbf74b61 Mon Sep 17 00:00:00 2001 From: cureb Date: Sun, 25 Jan 2026 23:03:16 +0000 Subject: [PATCH] =?UTF-8?q?baya=C4=9F=C4=B1=20=C5=9Fey=20eklendi=20breadcr?= =?UTF-8?q?umb=20kategori=20men=C3=BC=20vs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 17 + package.json | 1 + src/App.tsx | 96 +++-- src/adapters/responses/dealCardAdapter.ts | 2 +- src/adapters/responses/dealDetailAdapter.ts | 39 +- .../responses/sellerFromLookupAdapter.ts | 16 +- src/api/account/uploadAvatar.ts | 5 +- src/api/auth/login.ts | 5 +- src/api/auth/me.ts | 5 +- src/api/auth/register.ts | 5 +- src/api/axiosInstance.ts | 54 ++- src/api/category/getCategoryDetails.ts | 29 ++ src/api/deal/commentDeal.ts | 33 +- src/api/deal/getDeal.ts | 72 +++- src/api/deal/getTopDeals.ts | 11 + src/api/deal/newDeal.ts | 11 +- src/api/deal/searchDeal.ts | 21 +- src/api/deal/types.ts | 44 ++- src/api/deal/voteDeal.ts | 5 +- src/api/seller/from-lookup.ts | 13 +- src/api/seller/types.ts | 17 +- src/api/user/getUser.ts | 9 +- src/api/user/getUserDeals.ts | 26 ++ src/components/Auth/LoginModal.tsx | 103 +++-- .../Categories/CategoriesSidebar.tsx | 163 ++++++++ src/components/DealDetails/DealComments.tsx | 359 ++++++++++++++---- src/components/DealDetails/DealDetails.tsx | 6 +- src/components/DealDetails/DealImages.tsx | 33 +- src/components/DealDetails/DealNotice.tsx | 81 ++++ src/components/DealDetails/SimilarDeals.tsx | 98 +++++ src/components/FilterMenu.tsx | 78 ++++ src/components/Layout/AppShell.tsx | 51 +++ src/components/Layout/Footer.tsx | 41 +- src/components/Layout/Navbar/Navbar.tsx | 75 ++-- .../Layout/Navbar/Notifications.tsx | 47 +++ src/components/Layout/Navbar/Post.tsx | 0 src/components/Layout/Navbar/SearchBar.tsx | 4 +- src/components/Layout/Navbar/UserInfo.tsx | 49 ++- src/components/Shared/Breadcrumbs.tsx | 78 ++++ src/components/Shared/DealCardMain.tsx | 316 ++++++++------- src/components/Shared/DealsPresetTabs.tsx | 86 +++++ src/components/Shared/FilterPanel.tsx | 155 ++++++++ src/components/Sidebar/HotDealsSidebar.tsx | 157 ++++++++ src/context/AuthContext.tsx | 28 +- src/data/categories.ts | 58 +++ src/global copy.css | 95 +++++ src/global.css | 71 +++- src/hooks/useAuth.ts | 36 -- src/hooks/useAuthCheck.ts | 25 -- src/layouts/MainLayout.tsx | 48 ++- src/main.tsx | 1 + src/models/Shared/Breadcrumb.ts | 8 + src/models/Shared/SimilarDeal.ts | 11 + src/models/category/CategoryDetailsModel.ts | 12 + src/models/comment/Comment.ts | 4 +- src/models/deal/DealDetail.ts | 15 +- src/models/deal/DealNotice.ts | 12 + src/models/user/userStats.ts | 3 +- src/pages/CategoryPage.tsx | 176 +++++++++ src/pages/CreateDealPage.tsx | 58 ++- src/pages/DealDetailsPage.tsx | 119 ++++-- src/pages/HomePage.tsx | 229 +++++++---- src/pages/ProfilePage.tsx | 62 +-- src/pages/SearchPage.tsx | 17 +- src/pages/SubmitDealPage.tsx | 90 ----- src/services/userDealsService.ts | 12 + src/utils/heat.ts | 18 + 67 files changed, 2910 insertions(+), 814 deletions(-) create mode 100644 src/api/category/getCategoryDetails.ts create mode 100644 src/api/deal/getTopDeals.ts create mode 100644 src/api/user/getUserDeals.ts create mode 100644 src/components/Categories/CategoriesSidebar.tsx create mode 100644 src/components/DealDetails/DealNotice.tsx create mode 100644 src/components/DealDetails/SimilarDeals.tsx create mode 100644 src/components/FilterMenu.tsx create mode 100644 src/components/Layout/AppShell.tsx create mode 100644 src/components/Layout/Navbar/Notifications.tsx delete mode 100644 src/components/Layout/Navbar/Post.tsx create mode 100644 src/components/Shared/Breadcrumbs.tsx create mode 100644 src/components/Shared/DealsPresetTabs.tsx create mode 100644 src/components/Shared/FilterPanel.tsx create mode 100644 src/components/Sidebar/HotDealsSidebar.tsx create mode 100644 src/data/categories.ts create mode 100644 src/global copy.css delete mode 100644 src/hooks/useAuth.ts delete mode 100644 src/hooks/useAuthCheck.ts create mode 100644 src/models/Shared/Breadcrumb.ts create mode 100644 src/models/Shared/SimilarDeal.ts create mode 100644 src/models/category/CategoryDetailsModel.ts create mode 100644 src/models/deal/DealNotice.ts create mode 100644 src/pages/CategoryPage.tsx delete mode 100644 src/pages/SubmitDealPage.tsx create mode 100644 src/services/userDealsService.ts create mode 100644 src/utils/heat.ts diff --git a/package-lock.json b/package-lock.json index 7362c5b..5ccadd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@shared/contracts": "file:../Contracts", "@tailwindcss/vite": "^4.1.16", "axios": "^1.13.1", "lucide-react": "^0.562.0", @@ -34,6 +35,18 @@ "vite": "^7.1.7" } }, + "../Contracts": { + "name": "@shared/contracts", + "version": "0.0.0", + "dependencies": { + "zod": "^3.23.0" + }, + "devDependencies": { + "rimraf": "^6.0.0", + "tsup": "^8.0.0", + "typescript": "^5.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -1317,6 +1330,10 @@ "win32" ] }, + "node_modules/@shared/contracts": { + "resolved": "../Contracts", + "link": true + }, "node_modules/@tailwindcss/node": { "version": "4.1.16", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", diff --git a/package.json b/package.json index e5fbd63..7313178 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@shared/contracts": "file:../Contracts", "@tailwindcss/vite": "^4.1.16", "axios": "^1.13.1", "lucide-react": "^0.562.0", diff --git a/src/App.tsx b/src/App.tsx index 9085cf9..8954985 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,48 +1,72 @@ -import { useState } from "react" -import { BrowserRouter, Routes, Route } from "react-router-dom" -import { ErrorBoundary } from "./components/ErrorBoundary" -import HomePage from "./pages/HomePage" -import DealPage from "./pages/DealDetailsPage" +import { useState } from "react"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { ErrorBoundary } from "./components/ErrorBoundary"; +import HomePage from "./pages/HomePage"; +import DealPage from "./pages/DealDetailsPage"; +import CreateDealPage from "./pages/CreateDealPage"; +import AccountSettingsPage from "./pages/AccountSettingsPage"; +import ProfilePage from "./pages/ProfilePage"; +import SearchPage from "./pages/SearchPage"; +import CategoryPage from "./pages/CategoryPage"; // CategoryPage importu - -import CreateDealPage from "./pages/CreateDealPage" -import AccountSettingsPage from "./pages/AccountSettingsPage" -import ProfilePage from "./pages/ProfilePage" -import SearchPage from "./pages/SearchPage" - -import LoginModal from "./components/Auth/LoginModal" +import LoginModal from "./components/Auth/LoginModal"; export default function App() { - const [showLoginModal, setShowLoginModal] = useState(false) + const [showLoginModal, setShowLoginModal] = useState(false); return ( - - {showLoginModal && ( - setShowLoginModal(false)} - /> - )} + + {showLoginModal && ( + setShowLoginModal(false)} + /> + )} - - setShowLoginModal(true)} />} /> - - setShowLoginModal(true)} />} - /> - setShowLoginModal(true)} />} - /> + + {/* HomePage route */} + setShowLoginModal(true)} />} + /> + {/* DealPage route */} + setShowLoginModal(true)} />} + /> - } /> - } /> - } /> + {/* SearchPage route */} + setShowLoginModal(true)} />} + /> - - + {/* CategoryPage route */} + } + /> + + {/* CreateDealPage route */} + } + /> + + {/* AccountSettingsPage route */} + } + /> + + {/* ProfilePage route */} + } + /> + + - ) + ); } diff --git a/src/adapters/responses/dealCardAdapter.ts b/src/adapters/responses/dealCardAdapter.ts index c46847f..7a891f6 100644 --- a/src/adapters/responses/dealCardAdapter.ts +++ b/src/adapters/responses/dealCardAdapter.ts @@ -10,7 +10,7 @@ export function mapDealCardResponseToDeal( title: api.title, description: api.description, price: api.price ?? undefined, - + url: api.url, score: api.score, commentsCount:api.commentsCount, status: api.status, diff --git a/src/adapters/responses/dealDetailAdapter.ts b/src/adapters/responses/dealDetailAdapter.ts index 1e8e614..6338377 100644 --- a/src/adapters/responses/dealDetailAdapter.ts +++ b/src/adapters/responses/dealDetailAdapter.ts @@ -1,9 +1,20 @@ -import type { DealDetailResponse } from "../../api/deal/types" +import type { DealDetailResponse, SimilarDealResponse } from "../../api/deal/types" import type { DealDetail } from "../../models/deal/DealDetail" +import type { SimilarDeal } from "../../models/Shared/SimilarDeal" + +export function mapDealDetailResponseToDealDetail(api: DealDetailResponse): DealDetail { +const similarDeals: SimilarDeal[] = (api.similarDeals ?? []).map((d) => ({ + id: d.id, + title: d.title, + price: d.price ?? null, // ✅ undefined yok, null var + url: d.url ?? undefined, // url optional ise undefined OK + score: d.score ?? 0, + createdAt: d.createdAt, + sellerName: d.sellerName ?? "Bilinmiyor", + imageUrl: d.imageUrl || "/placeholder.png", +})) + -export function mapDealDetailResponseToDealDetail( - api: DealDetailResponse -): DealDetail { return { id: api.id, title: api.title, @@ -22,12 +33,13 @@ export function mapDealDetailResponseToDealDetail( updatedAt: api.updatedAt, user: api.user, - seller:{ - name:api.seller.name, - url:api.seller.url + seller: { + name: api.seller.name, + url: api.seller.url, }, + images: api.images.map((img) => ({ - url: img.imageUrl, + imageUrl: img.imageUrl, order: img.order, })), @@ -37,5 +49,16 @@ export function mapDealDetailResponseToDealDetail( createdAt: c.createdAt, user: c.user, })), + + breadcrumb: (api.breadcrumb ?? []).map((b) => ({ + id: b.id, + name: b.name, + slug: b.slug, + })), + + notice: api.notice ?? null, + + // ✅ yeni + similarDeals, } } diff --git a/src/adapters/responses/sellerFromLookupAdapter.ts b/src/adapters/responses/sellerFromLookupAdapter.ts index 49aebd0..67c5031 100644 --- a/src/adapters/responses/sellerFromLookupAdapter.ts +++ b/src/adapters/responses/sellerFromLookupAdapter.ts @@ -1,12 +1,14 @@ -import type { SellerFromLookupResponse } from "../../api/seller/types" import type { Seller } from "../../models/seller/Seller" +import type { SellerFromLookupResponse } from "../../api/seller/types" export function mapSellerFromLookupResponse( api: SellerFromLookupResponse -): Seller { - return{ - id:api.id, - name:api.name, - url:null +): Seller | null { + if (!api.found || !api.seller) return null + + return { + id: api.seller.id, + name: api.seller.name, + url: api.seller.url ?? null, + } } -} \ No newline at end of file diff --git a/src/api/account/uploadAvatar.ts b/src/api/account/uploadAvatar.ts index a6115d3..e2458cc 100644 --- a/src/api/account/uploadAvatar.ts +++ b/src/api/account/uploadAvatar.ts @@ -1,5 +1,8 @@ // src/api/account/uploadAvatar.ts import instance from "../axiosInstance" +import { endpoints } from "@shared/contracts" + +const { account } = endpoints export async function uploadAvatar(file: File) { try { @@ -12,7 +15,7 @@ export async function uploadAvatar(file: File) { }, }) - return data + return account.avatarUploadResponseSchema.parse(data) } catch (error: any) { const message = error.response?.data?.error || "Avatar yükleme hatası" console.error("Avatar yükleme hatası:", message) diff --git a/src/api/auth/login.ts b/src/api/auth/login.ts index e84f6f8..4de8251 100644 --- a/src/api/auth/login.ts +++ b/src/api/auth/login.ts @@ -1,10 +1,11 @@ // src/api/auth/login.ts import instance from "../axiosInstance" import type { LoginInput } from "./types" +import { endpoints } from "@shared/contracts" - +const { auth } = endpoints export async function login(input: LoginInput) { const { data } = await instance.post("/auth/login", input) - return data + return auth.authResponseSchema.parse(data) } diff --git a/src/api/auth/me.ts b/src/api/auth/me.ts index 57188b2..7629b98 100644 --- a/src/api/auth/me.ts +++ b/src/api/auth/me.ts @@ -1,10 +1,13 @@ // src/api/auth/me.ts import instance from "../axiosInstance" +import { endpoints } from "@shared/contracts" + +const { auth } = endpoints export async function me() { try { const { data } = await instance.get("/auth/me") - return data + return auth.meResponseSchema.parse(data) } catch (error: any) { const message = error.response?.data?.error || "Kullanıcı bilgisi alınamadı" console.error("Kullanıcı bilgisi alma hatası:", message) diff --git a/src/api/auth/register.ts b/src/api/auth/register.ts index b29ffba..c1d5ffc 100644 --- a/src/api/auth/register.ts +++ b/src/api/auth/register.ts @@ -1,5 +1,8 @@ // src/api/auth/register.ts import instance from "../axiosInstance" +import { endpoints } from "@shared/contracts" + +const { auth } = endpoints export async function register(username: string, email: string, password: string) { try { @@ -8,7 +11,7 @@ export async function register(username: string, email: string, password: string email, password, }) - return data // { token, user } + return auth.authResponseSchema.parse(data) } catch (error: any) { const message = error.response?.data?.message || "Kayıt başarısız" console.error("Register hatası:", message) diff --git a/src/api/axiosInstance.ts b/src/api/axiosInstance.ts index 58a5bbe..6aca311 100644 --- a/src/api/axiosInstance.ts +++ b/src/api/axiosInstance.ts @@ -1,27 +1,43 @@ -import axios from "axios"; +import axios from "axios" const instance = axios.create({ baseURL: "http://localhost:3000/api", - headers: { - "Content-Type": "application/json", - }, -}); + headers: { "Content-Type": "application/json" }, + withCredentials: true, // <-- refresh cookie için şart +}) instance.interceptors.request.use((config) => { - const token = localStorage.getItem("token"); - if (token) config.headers.Authorization = `Bearer ${token}`; - return config; -}); + const token = localStorage.getItem("token") + if (token) config.headers.Authorization = `Bearer ${token}` + return config +}) instance.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.clear(); - window.location.href = "/" // anasayfaya yönlendir - } - return Promise.reject(error); - } -); + (res) => res, + async (error) => { + const original = error.config -export default instance; + if (error.response?.status === 401 && !original?._retry) { + original._retry = true + try { + // refresh cookie ile yeni access token al + const { data } = await instance.post("/auth/refresh") + localStorage.setItem("token", data.token) + + original.headers = original.headers || {} + original.headers.Authorization = `Bearer ${data.token}` + return instance(original) + } catch (e) { + // refresh de başarısızsa logout + localStorage.removeItem("user") + localStorage.removeItem("token") + window.location.href = "/" + return Promise.reject(e) + } + } + + return Promise.reject(error) + } +) + +export default instance diff --git a/src/api/category/getCategoryDetails.ts b/src/api/category/getCategoryDetails.ts new file mode 100644 index 0000000..3a82cc4 --- /dev/null +++ b/src/api/category/getCategoryDetails.ts @@ -0,0 +1,29 @@ +// api/category.ts +import axios from "../axiosInstance"; // axios instance'ı import ediyoruz +import type { CategoryDetailsModel } from "../../models/category/CategoryDetailsModel"; // CategoryDetailsModel'i import ediyoruz + +export async function fetchCategoryDetails(slug: string): Promise { + try { + // API'den kategori detaylarını almak için axios instance'ını kullanıyoruz + const response = await axios.get(`/category/${slug}`); + + // Backend'den gelen veriyi CategoryDetailsModel formatına uygun şekilde döndürüyoruz + const data = response.data; + + return { + id: data.id, + name: data.name, + slug: data.slug, + description: data.description || "Açıklama bulunmuyor", + breadcrumb: data.breadcrumb.map((item: any) => ({ + id: item.id, + name: item.name, + slug: item.slug, + })), + }; + } catch (error) { + // Hata durumunda console loglama ve uygun mesaj döndürme + console.error("Error fetching category details:", error); + throw new Error("Kategori detayları alınırken bir hata oluştu"); + } +} diff --git a/src/api/deal/commentDeal.ts b/src/api/deal/commentDeal.ts index 9590882..25640e3 100644 --- a/src/api/deal/commentDeal.ts +++ b/src/api/deal/commentDeal.ts @@ -1,12 +1,13 @@ // src/api/deal/commentApi.ts import instance from "../axiosInstance" +import { endpoints } from "@shared/contracts" -import type { Comment } from "../../models" +const { comments } = endpoints -export async function getComments(dealId: number): Promise { +export async function getComments(dealId: number) { try { - const { data } = await instance.get(`/comments/${dealId}`) - return data + const { data } = await instance.get(`/comments/${dealId}`) + return comments.commentListResponseSchema.parse(data) } catch (error: any) { const message = error.response?.data?.error || "Yorumlar alınamadı" console.error("Yorumları alma hatası:", message) @@ -14,13 +15,31 @@ export async function getComments(dealId: number): Promise { } } -export async function postComment(dealId: number, text: string) { +export async function postComment(dealId: number, text: string, parentId?: number | null) { try { - const { data } = await instance.post("/comments", { dealId, text }) - return data + const payload: { dealId: number; text: string; parentId?: number | null } = { dealId, text } + if (typeof parentId === "number") payload.parentId = parentId + else if (parentId === null) payload.parentId = null + + const { data } = await instance.post("/comments", payload) + return comments.commentCreateResponseSchema.parse(data) } catch (error: any) { const message = error.response?.data?.error || "Yorum gönderilemedi" console.error("Yorum gönderme hatası:", message) throw new Error(message) } } + + +export async function deleteComment(commentId: number) { + try { + const { data } = await instance.delete(`/comments/${commentId}`) + return comments.commentDeleteResponseSchema.parse(data) + } catch (error: any) { + const message = + error.response?.data?.error || + (error.response?.status === 403 ? "Bu yorumu silmeye yetkin yok" : "Yorum silinemedi") + console.error("Yorum silme hatası:", message) + throw new Error(message) + } +} \ No newline at end of file diff --git a/src/api/deal/getDeal.ts b/src/api/deal/getDeal.ts index a287a9a..abd7b25 100644 --- a/src/api/deal/getDeal.ts +++ b/src/api/deal/getDeal.ts @@ -2,15 +2,73 @@ import instance from "../axiosInstance" import type { DealCard } from "../../models/deal/DealCard" import type { DealDetail } from "../../models/deal/DealDetail" +import { mapDealCardResponseToDeal } from "../../adapters/responses/dealCardAdapter" +import { mapDealDetailResponseToDealDetail } from "../../adapters/responses/dealDetailAdapter" +import { endpoints } from "@shared/contracts" + +const { deals } = endpoints + +export type DealPreset = "new" | "hot" | "trending" + +const PRESET_ENDPOINTS: Record = { + new: "/deals/new", + hot: "/deals/hot", + trending: "/deals/trending", +} + +const DEFAULT_LIMIT = 10 + +export async function fetchPresetDeals( + preset: DealPreset, + page = 1, + limit = DEFAULT_LIMIT +): Promise { + const requestData = deals.dealsListRequestSchema.parse({ + q: "", + page, + limit, + }) + const { data } = await instance.get(PRESET_ENDPOINTS[preset], { + params: requestData, + }) + const parsed = deals.dealsListResponseSchema.parse(data) + return parsed.results.map(mapDealCardResponseToDeal) +} export async function getDeals(page = 1): Promise { - const { data } = await instance.get<{ results: DealCard[] }>( - `/deals?page=${page}` - ) - console.log(data.results) - return data.results + return fetchPresetDeals("new", page, DEFAULT_LIMIT) } + export async function getDealDetail(id: number): Promise { - const { data } = await instance.get(`/deals/${id}`) - return data + const { data } = await instance.get(`/deals/${id}`) + + try { + const parsed = deals.dealDetailResponseSchema.parse(data) + return mapDealDetailResponseToDealDetail(parsed) + } catch (e) { + console.log("DEAL DETAIL RAW:", data) + console.error("DEAL DETAIL ZOD ERROR:", e) + throw e + } } + + +export async function fetchCategoryDeals( + categorySlug: string, // Category Slug for the specific category + page = 1, + limit = DEFAULT_LIMIT +): Promise { + const requestData = deals.dealsListRequestSchema.parse({ + q: "", + page, + limit, + categorySlug, // Passing categorySlug as a filter + }) + const { data } = await instance.get(`/category/${categorySlug}/deals`, { + params: requestData, + }) +console.log(data); + return data.map(mapDealCardResponseToDeal) +} + +// api/category.ts diff --git a/src/api/deal/getTopDeals.ts b/src/api/deal/getTopDeals.ts new file mode 100644 index 0000000..073c4d2 --- /dev/null +++ b/src/api/deal/getTopDeals.ts @@ -0,0 +1,11 @@ +import api from "../axiosInstance" // instance dosyanın yolu neyse onu düzelt +import type { DealCard } from "../../models/deal/DealCard" + +export type TopRange = "day" | "week" | "month" + +export async function fetchTopDeals(range: TopRange, limit = 6): Promise { + const { data } = await api.get("/deals/top", { + params: { range, limit }, + }) + return data +} diff --git a/src/api/deal/newDeal.ts b/src/api/deal/newDeal.ts index d22aaed..dae3fdf 100644 --- a/src/api/deal/newDeal.ts +++ b/src/api/deal/newDeal.ts @@ -1,18 +1,17 @@ // src/api/deal/createDeal.ts import instance from "../axiosInstance" +import { endpoints } from "@shared/contracts" + +const { deals } = endpoints export async function createDeal(formData: FormData) { try { - const { data } = await instance.post("/deals", formData, { - // Axios FormData gönderirken header'ı genelde kendi ayarlar. - // Ama bazı kurulumlarda gerekebilir: headers: { "Content-Type": "multipart/form-data" }, }) - return data + return deals.dealCreateResponseSchema.parse(data) } catch (error: any) { - const message = - error.response?.data?.error || "Fırsat eklenemedi" + const message = error.response?.data?.error || "Fırsat eklenemedi" console.error("Fırsat oluşturma hatası:", message) throw new Error(message) } diff --git a/src/api/deal/searchDeal.ts b/src/api/deal/searchDeal.ts index 46bfe79..64c8756 100644 --- a/src/api/deal/searchDeal.ts +++ b/src/api/deal/searchDeal.ts @@ -1,17 +1,22 @@ import instance from "../axiosInstance" import type { DealCard } from "../../models/deal/DealCard" +import { mapDealCardResponseToDeal } from "../../adapters/responses/dealCardAdapter" +import { endpoints } from "@shared/contracts" +const { deals } = endpoints export async function searchDeals( query: string, page = 1 ): Promise { - const { data } = await instance.get<{ results: DealCard[] }>( - `/deals/`, - { - params: { q: query, page }, - } - ) + const requestData = deals.dealsListRequestSchema.parse({ + q: query, + page, + }) + const { data } = await instance.get("/deals/", { + params: requestData, + }) - return data.results -} \ No newline at end of file + const response = deals.dealsListResponseSchema.parse(data) + return response.results.map(mapDealCardResponseToDeal) +} diff --git a/src/api/deal/types.ts b/src/api/deal/types.ts index 425cc5e..64495b2 100644 --- a/src/api/deal/types.ts +++ b/src/api/deal/types.ts @@ -11,7 +11,7 @@ export type DealCardResponse = { status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED" saleType: "ONLINE" | "OFFLINE" | "CODE" affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE" - + url?:string createdAt: string // ISO string updatedAt: string /* ---------- YAYINLAYAN KULLANICI (profil için yeterli) ---------- */ @@ -34,6 +34,26 @@ export type DealCardResponse = { commentsCount: number userVote?: "UP" | "DOWN" | null } +export type DealNoticeSeverity = "INFO" | "WARNING" | "DANGER" | "SUCCESS" +// src/api/deal/types.ts + +export type BreadcrumbItem = { + id: number + name: string + slug: string +} +export type SimilarDealResponse = { + id: number + title: string + price: number | null + url: string | null + score: number + createdAt: string + + sellerName:string + + imageUrl: string +} export type DealDetailResponse = { id: number @@ -58,9 +78,9 @@ export type DealDetailResponse = { avatarUrl: string | null } - seller: { - name: string - url:string|null + seller: { + name: string + url: string | null } images: { @@ -79,10 +99,26 @@ export type DealDetailResponse = { avatarUrl: string | null } }[] + + // ✅ breadcrumb + breadcrumb: BreadcrumbItem[] +similarDeals: SimilarDealResponse[] + notice?: { + id: number + dealId?: number + title: string + body?: string | null + severity: DealNoticeSeverity + isActive: boolean + createdBy: number + createdAt: string + updatedAt: string + } | null } + export type CreateDealRequest = { title: string description?: string diff --git a/src/api/deal/voteDeal.ts b/src/api/deal/voteDeal.ts index d2574bc..d24ba6f 100644 --- a/src/api/deal/voteDeal.ts +++ b/src/api/deal/voteDeal.ts @@ -1,5 +1,8 @@ // src/api/deal/voteDeal.ts import instance from "../axiosInstance" +import { endpoints } from "@shared/contracts" + +const { votes } = endpoints export async function voteDeal(dealId: number, type: 1 | 0 | -1) { try { @@ -7,7 +10,7 @@ export async function voteDeal(dealId: number, type: 1 | 0 | -1) { dealId, voteType: type, }) - return data + return votes.voteResponseSchema.parse(data) } catch (error) { console.error("Vote hatası:", error) throw error diff --git a/src/api/seller/from-lookup.ts b/src/api/seller/from-lookup.ts index 3058e54..aa10426 100644 --- a/src/api/seller/from-lookup.ts +++ b/src/api/seller/from-lookup.ts @@ -2,11 +2,16 @@ import type { SellerLookupInput } from "./types" import type { Seller } from "../../models/seller/Seller" import instance from "../axiosInstance" +import { mapSellerFromLookupResponse } from "../../adapters/responses/sellerFromLookupAdapter" +import { endpoints } from "@shared/contracts" +const { seller } = endpoints - -export async function lookupSellerFromLink(input: SellerLookupInput): Promise { - const { data } = await instance.post("/seller/from-link", input) - return data +export async function lookupSellerFromLink( + input: SellerLookupInput +): Promise { + const { data } = await instance.post("/seller/from-link", input) + const parsed = seller.sellerLookupResponseSchema.parse(data) + return mapSellerFromLookupResponse(parsed) } diff --git a/src/api/seller/types.ts b/src/api/seller/types.ts index 23fa645..6868b9e 100644 --- a/src/api/seller/types.ts +++ b/src/api/seller/types.ts @@ -1,9 +1,12 @@ -export type SellerFromLookupResponse= { - id:number - name:string +export type SellerFromLookupResponse = { + found: boolean + seller: { + id: number + name: string + url: string | null + } | null } - -export type SellerLookupInput={ - url:string|null -} \ No newline at end of file +export type SellerLookupInput = { + url: string | null +} diff --git a/src/api/user/getUser.ts b/src/api/user/getUser.ts index dec6c2b..a78a04f 100644 --- a/src/api/user/getUser.ts +++ b/src/api/user/getUser.ts @@ -1,15 +1,18 @@ // src/api/user/getUser.ts import instance from "../axiosInstance" import type { UserProfile } from "../../models/user/UserProfile" +import { endpoints } from "@shared/contracts" + +const { users } = endpoints export async function getUser(userName: string): Promise { try { - const { data } = await instance.get(`/user/${userName}`) - return data + const { data } = await instance.get(`/user/${userName}`) + return users.userProfileResponseSchema.parse(data) } catch (err: any) { console.error("Kullanıcı bilgileri alınamadı:", err) throw new Error( err.response?.data?.message || "Kullanıcı bilgileri alınamadı" ) } -} \ No newline at end of file +} diff --git a/src/api/user/getUserDeals.ts b/src/api/user/getUserDeals.ts new file mode 100644 index 0000000..a24844b --- /dev/null +++ b/src/api/user/getUserDeals.ts @@ -0,0 +1,26 @@ +import instance from "../axiosInstance" +import { endpoints } from "@shared/contracts" +import { mapDealCardResponseToDeal } from "../../adapters/responses/dealCardAdapter" + +const { deals } = endpoints + +type GetUserDealsOptions = { + q?: string + page?: number + limit?: number +} + +export async function getUserDeals(userName: string, options: GetUserDealsOptions = {}) { + const requestData = deals.dealsListRequestSchema.parse({ + q: options.q ?? "", + page: options.page ?? 1, + limit: options.limit ?? 20, + }) + + const { data } = await instance.get(`/deals/users/${userName}/deals`, { + params: requestData, + }) + + const parsed = deals.dealsListResponseSchema.parse(data) + return parsed.results.map(mapDealCardResponseToDeal) +} diff --git a/src/components/Auth/LoginModal.tsx b/src/components/Auth/LoginModal.tsx index f6ac41e..fed5795 100644 --- a/src/components/Auth/LoginModal.tsx +++ b/src/components/Auth/LoginModal.tsx @@ -1,43 +1,78 @@ -import { useState } from "react" -import { useAuth } from "../../context/AuthContext" -import { login as loginApi } from "../../api/auth/login" -import { register as registerApi } from "../../api/auth/register" -import type { LoginInput } from "../../api/auth/types" +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useAuth } from "../../context/AuthContext"; +import { login as loginApi } from "../../api/auth/login"; +import { register as registerApi } from "../../api/auth/register"; +import type { LoginInput } from "../../api/auth/types"; + type LoginModalProps = { - onClose: () => void -} + onClose: () => void; +}; export default function LoginModal({ onClose }: LoginModalProps) { - const [isRegister, setIsRegister] = useState(false) - const [username, setUsername] = useState("") - const [email, setEmail] = useState("") - const [password, setPassword] = useState("") - const { login } = useAuth() // global auth fonksiyonu + const [isRegister, setIsRegister] = useState(false); // Kayıt olma ya da giriş yapma durumu + const [username, setUsername] = useState(""); // Kullanıcı adı + const [email, setEmail] = useState(""); // E-posta + const [password, setPassword] = useState(""); // Şifre + const [showModal, setShowModal] = useState(false); // Modal'ın görünürlüğünü kontrol eden state + const { login } = useAuth(); + // Modal açıldığında + useEffect(() => { + setShowModal(true); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", onKeyDown); + + // Scroll kilidi + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + + return () => { + document.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = prev; + }; + }, [onClose]); + + // Giriş veya kayıt işlemi const handleSubmit = async () => { try { if (isRegister) { - const data = await registerApi(username, email, password) - login(data.user, data.token) + const data = await registerApi(username, email, password); + login(data.user, data.token); // Başarılı giriş için } else { - - const input: LoginInput = { - email, - password, + const input: LoginInput = { email, password }; + const data = await loginApi(input); // Başarılı login için + login(data.user, data.token); } - const data = await loginApi(input) - login(data.user, data.token) - } - onClose() - } catch (err) { - alert("Giriş/Kayıt başarısız") + onClose(); // Giriş yaptıktan sonra modal'ı kapat + } catch { + alert("Giriş/Kayıt başarısız"); } - } + }; + + const modal = ( +
+ {/* Overlay */} + - return ( -
-
-

{isRegister ? "Kayıt Ol" : "Giriş Yap"}

@@ -59,6 +94,7 @@ export default function LoginModal({ onClose }: LoginModalProps) { onChange={(e) => setEmail(e.target.value)} className="w-full mb-3 p-3 rounded bg-surface border" /> +
-
@@ -84,5 +123,7 @@ export default function LoginModal({ onClose }: LoginModalProps) {

- ) + ); + + return createPortal(modal, document.body); // Modal'ı body'ye render ediyoruz } diff --git a/src/components/Categories/CategoriesSidebar.tsx b/src/components/Categories/CategoriesSidebar.tsx new file mode 100644 index 0000000..5e0c8e2 --- /dev/null +++ b/src/components/Categories/CategoriesSidebar.tsx @@ -0,0 +1,163 @@ +import React, { useMemo, useState } from "react" +import { X, ChevronRight } from "lucide-react" +import { useNavigate } from "react-router-dom" // useNavigate'i ekliyoruz +import type { CategoryNode } from "../../data/categories" + +type Props = { + isOpen: boolean + categories: CategoryNode[] + activeSlug?: string | null + onClose: () => void + onSelect: (slug: string) => void +} + +function TreeNode({ + node, + level, + activeSlug, + expanded, + onToggle, + onSelect, +}: { + node: CategoryNode + level: number + activeSlug?: string | null + expanded: boolean + onToggle: () => void + onSelect: (slug: string) => void +}) { + const hasChildren = (node.children?.length ?? 0) > 0 + const isActive = activeSlug === node.slug + + return ( +
+
onSelect(node.slug)} // Kategori seçildiğinde onSelect fonksiyonu çalışacak + role="button" + tabIndex={0} + > + {node.name} + + {hasChildren && ( + + )} +
+ + {hasChildren && expanded && ( +
+ {node.children!.map((c) => ( + + ))} +
+ )} +
+ ) +} + +function CategoryBranch({ + node, + level, + activeSlug, + onSelect, +}: { + node: CategoryNode + level: number + activeSlug?: string | null + onSelect: (slug: string) => void +}) { + const hasChildren = (node.children?.length ?? 0) > 0 + const [expanded, setExpanded] = useState(level <= 0) // istersen false yap + + return ( + setExpanded((v) => !v)} + onSelect={onSelect} + /> + ) +} + +export default function CategoriesSidebar({ + isOpen, + categories, + activeSlug, + onClose, + onSelect, +}: Props) { + const navigate = useNavigate() // React Router'ın navigate hook'u + const visible = useMemo(() => (isOpen ? "translate-x-0" : "-translate-x-full"), [isOpen]) + + // Kategori seçildiğinde yönlendirme yapılacak + const handleSelect = (slug: string) => { + onSelect(slug) // Seçim sonrası onSelect callback'ini çalıştırıyoruz + navigate(`/category/${slug}`) // Kategori sayfasına yönlendirme + onClose() // Sidebar'ı kapatıyoruz + } + + return ( + <> + {isOpen && ( +