bayağı şey eklendi breadcrumb kategori menü vs
This commit is contained in:
parent
a48d32fdec
commit
84e1ef9ee6
17
package-lock.json
generated
17
package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@shared/contracts": "file:../Contracts",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
|
@ -34,6 +35,18 @@
|
||||||
"vite": "^7.1.7"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||||
|
|
@ -1317,6 +1330,10 @@
|
||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@shared/contracts": {
|
||||||
|
"resolved": "../Contracts",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.16",
|
"version": "4.1.16",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@shared/contracts": "file:../Contracts",
|
||||||
"@tailwindcss/vite": "^4.1.16",
|
"@tailwindcss/vite": "^4.1.16",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
|
|
||||||
94
src/App.tsx
94
src/App.tsx
|
|
@ -1,48 +1,72 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom"
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary"
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import HomePage from "./pages/HomePage"
|
import HomePage from "./pages/HomePage";
|
||||||
import DealPage from "./pages/DealDetailsPage"
|
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 LoginModal from "./components/Auth/LoginModal";
|
||||||
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"
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [showLoginModal, setShowLoginModal] = useState(false)
|
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
{showLoginModal && (
|
{showLoginModal && (
|
||||||
<LoginModal
|
<LoginModal
|
||||||
onClose={() => setShowLoginModal(false)}
|
onClose={() => setShowLoginModal(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage onRequireLogin={() => setShowLoginModal(true)} />} />
|
{/* HomePage route */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<HomePage onRequireLogin={() => setShowLoginModal(true)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
{/* DealPage route */}
|
||||||
path="/deal/:id"
|
<Route
|
||||||
element={<DealPage onRequireLogin={() => setShowLoginModal(true)} />}
|
path="/deal/:id"
|
||||||
/>
|
element={<DealPage onRequireLogin={() => setShowLoginModal(true)} />}
|
||||||
<Route
|
/>
|
||||||
path="/search"
|
|
||||||
element={<SearchPage onRequireLogin={() => setShowLoginModal(true)} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
{/* SearchPage route */}
|
||||||
|
<Route
|
||||||
|
path="/search"
|
||||||
|
element={<SearchPage onRequireLogin={() => setShowLoginModal(true)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="/create-deal" element={<CreateDealPage />} />
|
{/* CategoryPage route */}
|
||||||
<Route path="/account" element={<AccountSettingsPage />} />
|
<Route
|
||||||
<Route path="/user/:userName" element={<ProfilePage />} />
|
path="/category/:categorySlug"
|
||||||
|
element={<CategoryPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
</Routes>
|
{/* CreateDealPage route */}
|
||||||
</BrowserRouter>
|
<Route
|
||||||
|
path="/create-deal"
|
||||||
|
element={<CreateDealPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* AccountSettingsPage route */}
|
||||||
|
<Route
|
||||||
|
path="/account"
|
||||||
|
element={<AccountSettingsPage />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ProfilePage route */}
|
||||||
|
<Route
|
||||||
|
path="/user/:userName"
|
||||||
|
element={<ProfilePage />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export function mapDealCardResponseToDeal(
|
||||||
title: api.title,
|
title: api.title,
|
||||||
description: api.description,
|
description: api.description,
|
||||||
price: api.price ?? undefined,
|
price: api.price ?? undefined,
|
||||||
|
url: api.url,
|
||||||
score: api.score,
|
score: api.score,
|
||||||
commentsCount:api.commentsCount,
|
commentsCount:api.commentsCount,
|
||||||
status: api.status,
|
status: api.status,
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 {
|
return {
|
||||||
id: api.id,
|
id: api.id,
|
||||||
title: api.title,
|
title: api.title,
|
||||||
|
|
@ -22,12 +33,13 @@ export function mapDealDetailResponseToDealDetail(
|
||||||
updatedAt: api.updatedAt,
|
updatedAt: api.updatedAt,
|
||||||
|
|
||||||
user: api.user,
|
user: api.user,
|
||||||
seller:{
|
seller: {
|
||||||
name:api.seller.name,
|
name: api.seller.name,
|
||||||
url:api.seller.url
|
url: api.seller.url,
|
||||||
},
|
},
|
||||||
|
|
||||||
images: api.images.map((img) => ({
|
images: api.images.map((img) => ({
|
||||||
url: img.imageUrl,
|
imageUrl: img.imageUrl,
|
||||||
order: img.order,
|
order: img.order,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
|
@ -37,5 +49,16 @@ export function mapDealDetailResponseToDealDetail(
|
||||||
createdAt: c.createdAt,
|
createdAt: c.createdAt,
|
||||||
user: c.user,
|
user: c.user,
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
breadcrumb: (api.breadcrumb ?? []).map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
name: b.name,
|
||||||
|
slug: b.slug,
|
||||||
|
})),
|
||||||
|
|
||||||
|
notice: api.notice ?? null,
|
||||||
|
|
||||||
|
// ✅ yeni
|
||||||
|
similarDeals,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import type { SellerFromLookupResponse } from "../../api/seller/types"
|
|
||||||
import type { Seller } from "../../models/seller/Seller"
|
import type { Seller } from "../../models/seller/Seller"
|
||||||
|
import type { SellerFromLookupResponse } from "../../api/seller/types"
|
||||||
|
|
||||||
export function mapSellerFromLookupResponse(
|
export function mapSellerFromLookupResponse(
|
||||||
api: SellerFromLookupResponse
|
api: SellerFromLookupResponse
|
||||||
): Seller {
|
): Seller | null {
|
||||||
return{
|
if (!api.found || !api.seller) return null
|
||||||
id:api.id,
|
|
||||||
name:api.name,
|
return {
|
||||||
url:null
|
id: api.seller.id,
|
||||||
}
|
name: api.seller.name,
|
||||||
|
url: api.seller.url ?? null,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
// src/api/account/uploadAvatar.ts
|
// src/api/account/uploadAvatar.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { account } = endpoints
|
||||||
|
|
||||||
export async function uploadAvatar(file: File) {
|
export async function uploadAvatar(file: File) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -12,7 +15,7 @@ export async function uploadAvatar(file: File) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return data
|
return account.avatarUploadResponseSchema.parse(data)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.error || "Avatar yükleme hatası"
|
const message = error.response?.data?.error || "Avatar yükleme hatası"
|
||||||
console.error("Avatar yükleme hatası:", message)
|
console.error("Avatar yükleme hatası:", message)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
// src/api/auth/login.ts
|
// src/api/auth/login.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
import type { LoginInput } from "./types"
|
import type { LoginInput } from "./types"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { auth } = endpoints
|
||||||
|
|
||||||
export async function login(input: LoginInput) {
|
export async function login(input: LoginInput) {
|
||||||
const { data } = await instance.post("/auth/login", input)
|
const { data } = await instance.post("/auth/login", input)
|
||||||
return data
|
return auth.authResponseSchema.parse(data)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
// src/api/auth/me.ts
|
// src/api/auth/me.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { auth } = endpoints
|
||||||
|
|
||||||
export async function me() {
|
export async function me() {
|
||||||
try {
|
try {
|
||||||
const { data } = await instance.get("/auth/me")
|
const { data } = await instance.get("/auth/me")
|
||||||
return data
|
return auth.meResponseSchema.parse(data)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.error || "Kullanıcı bilgisi alınamadı"
|
const message = error.response?.data?.error || "Kullanıcı bilgisi alınamadı"
|
||||||
console.error("Kullanıcı bilgisi alma hatası:", message)
|
console.error("Kullanıcı bilgisi alma hatası:", message)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
// src/api/auth/register.ts
|
// src/api/auth/register.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { auth } = endpoints
|
||||||
|
|
||||||
export async function register(username: string, email: string, password: string) {
|
export async function register(username: string, email: string, password: string) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -8,7 +11,7 @@ export async function register(username: string, email: string, password: string
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
return data // { token, user }
|
return auth.authResponseSchema.parse(data)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message || "Kayıt başarısız"
|
const message = error.response?.data?.message || "Kayıt başarısız"
|
||||||
console.error("Register hatası:", message)
|
console.error("Register hatası:", message)
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,43 @@
|
||||||
import axios from "axios";
|
import axios from "axios"
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: "http://localhost:3000/api",
|
baseURL: "http://localhost:3000/api",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
withCredentials: true, // <-- refresh cookie için şart
|
||||||
},
|
})
|
||||||
});
|
|
||||||
|
|
||||||
instance.interceptors.request.use((config) => {
|
instance.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem("token");
|
const token = localStorage.getItem("token")
|
||||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
return config;
|
return config
|
||||||
});
|
})
|
||||||
|
|
||||||
instance.interceptors.response.use(
|
instance.interceptors.response.use(
|
||||||
(response) => response,
|
(res) => res,
|
||||||
(error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
const original = error.config
|
||||||
localStorage.clear();
|
|
||||||
window.location.href = "/" // anasayfaya yönlendir
|
|
||||||
}
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
29
src/api/category/getCategoryDetails.ts
Normal file
29
src/api/category/getCategoryDetails.ts
Normal file
|
|
@ -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<CategoryDetailsModel> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
// src/api/deal/commentApi.ts
|
// src/api/deal/commentApi.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
import type { Comment } from "../../models"
|
const { comments } = endpoints
|
||||||
|
|
||||||
export async function getComments(dealId: number): Promise<Comment[]> {
|
export async function getComments(dealId: number) {
|
||||||
try {
|
try {
|
||||||
const { data } = await instance.get<Comment[]>(`/comments/${dealId}`)
|
const { data } = await instance.get(`/comments/${dealId}`)
|
||||||
return data
|
return comments.commentListResponseSchema.parse(data)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.error || "Yorumlar alınamadı"
|
const message = error.response?.data?.error || "Yorumlar alınamadı"
|
||||||
console.error("Yorumları alma hatası:", message)
|
console.error("Yorumları alma hatası:", message)
|
||||||
|
|
@ -14,13 +15,31 @@ export async function getComments(dealId: number): Promise<Comment[]> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postComment(dealId: number, text: string) {
|
export async function postComment(dealId: number, text: string, parentId?: number | null) {
|
||||||
try {
|
try {
|
||||||
const { data } = await instance.post("/comments", { dealId, text })
|
const payload: { dealId: number; text: string; parentId?: number | null } = { dealId, text }
|
||||||
return data
|
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) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.error || "Yorum gönderilemedi"
|
const message = error.response?.data?.error || "Yorum gönderilemedi"
|
||||||
console.error("Yorum gönderme hatası:", message)
|
console.error("Yorum gönderme hatası:", message)
|
||||||
throw new Error(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,15 +2,73 @@
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
import type { DealCard } from "../../models/deal/DealCard"
|
import type { DealCard } from "../../models/deal/DealCard"
|
||||||
import type { DealDetail } from "../../models/deal/DealDetail"
|
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<DealPreset, string> = {
|
||||||
|
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<DealCard[]> {
|
||||||
|
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<DealCard[]> {
|
export async function getDeals(page = 1): Promise<DealCard[]> {
|
||||||
const { data } = await instance.get<{ results: DealCard[] }>(
|
return fetchPresetDeals("new", page, DEFAULT_LIMIT)
|
||||||
`/deals?page=${page}`
|
|
||||||
)
|
|
||||||
console.log(data.results)
|
|
||||||
return data.results
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDealDetail(id: number): Promise<DealDetail> {
|
export async function getDealDetail(id: number): Promise<DealDetail> {
|
||||||
const { data } = await instance.get<DealDetail>(`/deals/${id}`)
|
const { data } = await instance.get(`/deals/${id}`)
|
||||||
return data
|
|
||||||
|
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<DealCard[]> {
|
||||||
|
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
|
||||||
|
|
|
||||||
11
src/api/deal/getTopDeals.ts
Normal file
11
src/api/deal/getTopDeals.ts
Normal file
|
|
@ -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<DealCard[]> {
|
||||||
|
const { data } = await api.get<DealCard[]>("/deals/top", {
|
||||||
|
params: { range, limit },
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,17 @@
|
||||||
// src/api/deal/createDeal.ts
|
// src/api/deal/createDeal.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { deals } = endpoints
|
||||||
|
|
||||||
export async function createDeal(formData: FormData) {
|
export async function createDeal(formData: FormData) {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
const { data } = await instance.post("/deals", formData, {
|
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" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
})
|
})
|
||||||
return data
|
return deals.dealCreateResponseSchema.parse(data)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message = error.response?.data?.error || "Fırsat eklenemedi"
|
||||||
error.response?.data?.error || "Fırsat eklenemedi"
|
|
||||||
console.error("Fırsat oluşturma hatası:", message)
|
console.error("Fırsat oluşturma hatası:", message)
|
||||||
throw new Error(message)
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,22 @@
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
import type { DealCard } from "../../models/deal/DealCard"
|
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(
|
export async function searchDeals(
|
||||||
query: string,
|
query: string,
|
||||||
page = 1
|
page = 1
|
||||||
): Promise<DealCard[]> {
|
): Promise<DealCard[]> {
|
||||||
const { data } = await instance.get<{ results: DealCard[] }>(
|
const requestData = deals.dealsListRequestSchema.parse({
|
||||||
`/deals/`,
|
q: query,
|
||||||
{
|
page,
|
||||||
params: { q: query, page },
|
})
|
||||||
}
|
const { data } = await instance.get("/deals/", {
|
||||||
)
|
params: requestData,
|
||||||
|
})
|
||||||
|
|
||||||
return data.results
|
const response = deals.dealsListResponseSchema.parse(data)
|
||||||
|
return response.results.map(mapDealCardResponseToDeal)
|
||||||
}
|
}
|
||||||
|
|
@ -11,7 +11,7 @@ export type DealCardResponse = {
|
||||||
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED"
|
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED"
|
||||||
saleType: "ONLINE" | "OFFLINE" | "CODE"
|
saleType: "ONLINE" | "OFFLINE" | "CODE"
|
||||||
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE"
|
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE"
|
||||||
|
url?:string
|
||||||
createdAt: string // ISO string
|
createdAt: string // ISO string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
/* ---------- YAYINLAYAN KULLANICI (profil için yeterli) ---------- */
|
/* ---------- YAYINLAYAN KULLANICI (profil için yeterli) ---------- */
|
||||||
|
|
@ -34,6 +34,26 @@ export type DealCardResponse = {
|
||||||
commentsCount: number
|
commentsCount: number
|
||||||
userVote?: "UP" | "DOWN" | null
|
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 = {
|
export type DealDetailResponse = {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -60,7 +80,7 @@ export type DealDetailResponse = {
|
||||||
|
|
||||||
seller: {
|
seller: {
|
||||||
name: string
|
name: string
|
||||||
url:string|null
|
url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
images: {
|
images: {
|
||||||
|
|
@ -79,10 +99,26 @@ export type DealDetailResponse = {
|
||||||
avatarUrl: string | null
|
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 = {
|
export type CreateDealRequest = {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
// src/api/deal/voteDeal.ts
|
// src/api/deal/voteDeal.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { votes } = endpoints
|
||||||
|
|
||||||
export async function voteDeal(dealId: number, type: 1 | 0 | -1) {
|
export async function voteDeal(dealId: number, type: 1 | 0 | -1) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -7,7 +10,7 @@ export async function voteDeal(dealId: number, type: 1 | 0 | -1) {
|
||||||
dealId,
|
dealId,
|
||||||
voteType: type,
|
voteType: type,
|
||||||
})
|
})
|
||||||
return data
|
return votes.voteResponseSchema.parse(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Vote hatası:", error)
|
console.error("Vote hatası:", error)
|
||||||
throw error
|
throw error
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,16 @@ import type { SellerLookupInput } from "./types"
|
||||||
import type { Seller } from "../../models/seller/Seller"
|
import type { Seller } from "../../models/seller/Seller"
|
||||||
|
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
|
import { mapSellerFromLookupResponse } from "../../adapters/responses/sellerFromLookupAdapter"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { seller } = endpoints
|
||||||
|
|
||||||
|
export async function lookupSellerFromLink(
|
||||||
export async function lookupSellerFromLink(input: SellerLookupInput): Promise<Seller> {
|
input: SellerLookupInput
|
||||||
const { data } = await instance.post<Seller>("/seller/from-link", input)
|
): Promise<Seller | null> {
|
||||||
return data
|
const { data } = await instance.post("/seller/from-link", input)
|
||||||
|
const parsed = seller.sellerLookupResponseSchema.parse(data)
|
||||||
|
return mapSellerFromLookupResponse(parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
export type SellerFromLookupResponse= {
|
export type SellerFromLookupResponse = {
|
||||||
id:number
|
found: boolean
|
||||||
name:string
|
seller: {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
url: string | null
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SellerLookupInput = {
|
||||||
export type SellerLookupInput={
|
url: string | null
|
||||||
url:string|null
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
// src/api/user/getUser.ts
|
// src/api/user/getUser.ts
|
||||||
import instance from "../axiosInstance"
|
import instance from "../axiosInstance"
|
||||||
import type { UserProfile } from "../../models/user/UserProfile"
|
import type { UserProfile } from "../../models/user/UserProfile"
|
||||||
|
import { endpoints } from "@shared/contracts"
|
||||||
|
|
||||||
|
const { users } = endpoints
|
||||||
|
|
||||||
export async function getUser(userName: string): Promise<UserProfile> {
|
export async function getUser(userName: string): Promise<UserProfile> {
|
||||||
try {
|
try {
|
||||||
const { data } = await instance.get<UserProfile>(`/user/${userName}`)
|
const { data } = await instance.get(`/user/${userName}`)
|
||||||
return data
|
return users.userProfileResponseSchema.parse(data)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Kullanıcı bilgileri alınamadı:", err)
|
console.error("Kullanıcı bilgileri alınamadı:", err)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
26
src/api/user/getUserDeals.ts
Normal file
26
src/api/user/getUserDeals.ts
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -1,43 +1,78 @@
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react";
|
||||||
import { useAuth } from "../../context/AuthContext"
|
import { createPortal } from "react-dom";
|
||||||
import { login as loginApi } from "../../api/auth/login"
|
import { useAuth } from "../../context/AuthContext";
|
||||||
import { register as registerApi } from "../../api/auth/register"
|
import { login as loginApi } from "../../api/auth/login";
|
||||||
import type { LoginInput } from "../../api/auth/types"
|
import { register as registerApi } from "../../api/auth/register";
|
||||||
|
import type { LoginInput } from "../../api/auth/types";
|
||||||
|
|
||||||
type LoginModalProps = {
|
type LoginModalProps = {
|
||||||
onClose: () => void
|
onClose: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function LoginModal({ onClose }: LoginModalProps) {
|
export default function LoginModal({ onClose }: LoginModalProps) {
|
||||||
const [isRegister, setIsRegister] = useState(false)
|
const [isRegister, setIsRegister] = useState(false); // Kayıt olma ya da giriş yapma durumu
|
||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState(""); // Kullanıcı adı
|
||||||
const [email, setEmail] = useState("")
|
const [email, setEmail] = useState(""); // E-posta
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState(""); // Şifre
|
||||||
const { login } = useAuth() // global auth fonksiyonu
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
if (isRegister) {
|
if (isRegister) {
|
||||||
const data = await registerApi(username, email, password)
|
const data = await registerApi(username, email, password);
|
||||||
login(data.user, data.token)
|
login(data.user, data.token); // Başarılı giriş için
|
||||||
} else {
|
} else {
|
||||||
|
const input: LoginInput = { email, password };
|
||||||
const input: LoginInput = {
|
const data = await loginApi(input); // Başarılı login için
|
||||||
email,
|
login(data.user, data.token);
|
||||||
password,
|
|
||||||
}
|
}
|
||||||
const data = await loginApi(input)
|
onClose(); // Giriş yaptıktan sonra modal'ı kapat
|
||||||
login(data.user, data.token)
|
} catch {
|
||||||
}
|
alert("Giriş/Kayıt başarısız");
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
alert("Giriş/Kayıt başarısız")
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const modal = (
|
||||||
|
<div className="fixed inset-0 z-[999] flex items-center justify-center">
|
||||||
|
{/* Overlay */}
|
||||||
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
className="absolute inset-0 bg-black/60 z-40"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Modal Content */}
|
||||||
|
<div
|
||||||
|
className="relative z-[1000] bg-white p-8 rounded-2xl w-96 shadow-xl transition-all duration-500 ease-in-out"
|
||||||
|
style={{
|
||||||
|
// Aşağıdan yukarıya kaydırma animasyonu
|
||||||
|
transform: showModal ? "translateY(0)" : "translateY(100%)", // Modal'ı yukarı kaydırıyoruz
|
||||||
|
opacity: showModal ? 1 : 0, // Opaklık değişimi
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button onClick={onClose} className="absolute top-3 right-3">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 flex items-center justify-center bg-black/60">
|
|
||||||
<div className="bg-background p-8 rounded-2xl w-96 shadow-xl relative">
|
|
||||||
<button onClick={onClose} className="absolute top-3 right-3">×</button>
|
|
||||||
<h2 className="text-xl font-semibold mb-6 text-center">
|
<h2 className="text-xl font-semibold mb-6 text-center">
|
||||||
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -59,6 +94,7 @@ export default function LoginModal({ onClose }: LoginModalProps) {
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
className="w-full mb-3 p-3 rounded bg-surface border"
|
className="w-full mb-3 p-3 rounded bg-surface border"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Şifre"
|
placeholder="Şifre"
|
||||||
|
|
@ -68,7 +104,10 @@ export default function LoginModal({ onClose }: LoginModalProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<button onClick={handleSubmit} className="bg-primary text-white px-6 py-2 rounded">
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="bg-primary text-white px-6 py-2 rounded"
|
||||||
|
>
|
||||||
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
{isRegister ? "Kayıt Ol" : "Giriş Yap"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,5 +123,7 @@ export default function LoginModal({ onClose }: LoginModalProps) {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
|
return createPortal(modal, document.body); // Modal'ı body'ye render ediyoruz
|
||||||
}
|
}
|
||||||
|
|
|
||||||
163
src/components/Categories/CategoriesSidebar.tsx
Normal file
163
src/components/Categories/CategoriesSidebar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"flex items-center justify-between gap-2 rounded-xl px-3 py-2",
|
||||||
|
"cursor-pointer select-none",
|
||||||
|
"hover:bg-white/5 transition",
|
||||||
|
isActive ? "bg-white/10 text-text" : "text-text-muted hover:text-text",
|
||||||
|
].join(" ")}
|
||||||
|
style={{ paddingLeft: 12 + level * 12 }}
|
||||||
|
onClick={() => onSelect(node.slug)} // Kategori seçildiğinde onSelect fonksiyonu çalışacak
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<span className="truncate text-sm font-medium">{node.name}</span>
|
||||||
|
|
||||||
|
{hasChildren && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggle()
|
||||||
|
}}
|
||||||
|
className="p-2 rounded-lg hover:bg-white/5 transition text-text-muted cursor-pointer"
|
||||||
|
aria-label="Toggle children"
|
||||||
|
>
|
||||||
|
<ChevronRight className={["w-4 h-4 transition", expanded ? "rotate-90" : ""].join(" ")} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChildren && expanded && (
|
||||||
|
<div className="mt-1 space-y-1">
|
||||||
|
{node.children!.map((c) => (
|
||||||
|
<CategoryBranch
|
||||||
|
key={c.id}
|
||||||
|
node={c}
|
||||||
|
level={level + 1}
|
||||||
|
activeSlug={activeSlug}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<TreeNode
|
||||||
|
node={node}
|
||||||
|
level={level}
|
||||||
|
activeSlug={activeSlug}
|
||||||
|
expanded={hasChildren ? expanded : false}
|
||||||
|
onToggle={() => 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 && (
|
||||||
|
<div className="fixed inset-0 z-[90] bg-black/40" onClick={onClose} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={[
|
||||||
|
"fixed left-0 top-0 h-full w-[320px] z-[100]",
|
||||||
|
"bg-surface border-r border-white/10",
|
||||||
|
"transform transition-transform duration-200",
|
||||||
|
visible,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||||
|
<div className="text-sm font-semibold text-grey">Kategoriler</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl hover:bg-white/5 transition text-text-muted cursor-pointer"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 text-grey space-y-1 overflow-y-auto h-[calc(100%-64px)]">
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<CategoryBranch
|
||||||
|
key={cat.id}
|
||||||
|
node={cat}
|
||||||
|
level={0}
|
||||||
|
activeSlug={activeSlug}
|
||||||
|
onSelect={handleSelect} // handleSelect fonksiyonunu onSelect olarak veriyoruz
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { useEffect, useState } from "react"
|
// src/components/DealDetails/DealComments.tsx
|
||||||
|
import React, { useEffect, useMemo, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { Heart, MessageCircle, MoreHorizontal } from "lucide-react"
|
import { Heart, MessageCircle, MoreHorizontal, Trash2 } from "lucide-react"
|
||||||
import { getComments, postComment } from "../../api/deal/commentDeal"
|
import { getComments, postComment, deleteComment } from "../../api/deal/commentDeal"
|
||||||
import { useAuth } from "../../context/AuthContext"
|
import { useAuth } from "../../context/AuthContext"
|
||||||
import { timeAgo } from "../../utils/timeAgo"
|
import { timeAgo } from "../../utils/timeAgo"
|
||||||
import type { Comment } from "../../models/comment/Comment"
|
import type { Comment } from "../../models/comment/Comment"
|
||||||
|
|
@ -11,11 +12,214 @@ type DealCommentsProps = {
|
||||||
onRequireLogin: () => void
|
onRequireLogin: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CommentRowProps = {
|
||||||
|
c: Comment
|
||||||
|
isReply?: boolean
|
||||||
|
|
||||||
|
currentUserId: number | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
onRequireLogin: () => void
|
||||||
|
|
||||||
|
deletingId: number | null
|
||||||
|
onDelete: (commentId: number) => void
|
||||||
|
|
||||||
|
replyToId: number | null
|
||||||
|
setReplyToId: (id: number | null) => void
|
||||||
|
replyText: string
|
||||||
|
setReplyText: (v: string) => void
|
||||||
|
onReplySubmit: (e: React.FormEvent, parentId: number) => void
|
||||||
|
|
||||||
|
repliesByParent: Map<number, Comment[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentRow({
|
||||||
|
c,
|
||||||
|
isReply = false,
|
||||||
|
currentUserId,
|
||||||
|
isAuthenticated,
|
||||||
|
onRequireLogin,
|
||||||
|
deletingId,
|
||||||
|
onDelete,
|
||||||
|
replyToId,
|
||||||
|
setReplyToId,
|
||||||
|
replyText,
|
||||||
|
setReplyText,
|
||||||
|
onReplySubmit,
|
||||||
|
repliesByParent,
|
||||||
|
}: CommentRowProps) {
|
||||||
|
const isMine = currentUserId != null && c.user?.id === currentUserId
|
||||||
|
const replies = repliesByParent.get(c.id) ?? []
|
||||||
|
|
||||||
|
const wrapClass = isReply
|
||||||
|
? "mt-4 rounded-2xl border border-white/10 bg-background/60 p-4 relative"
|
||||||
|
: "py-5 first:pt-0 last:pb-0"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={wrapClass}>
|
||||||
|
{isReply && (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute left-0 top-0 h-full w-1 rounded-l-2xl bg-primary/30"
|
||||||
|
/>
|
||||||
|
<div className="mb-2 flex items-center gap-2 pl-2">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||||
|
Yanıt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex gap-3 ${isReply ? "pl-2" : ""}`}>
|
||||||
|
<Link to={`/user/${c.user.username}`} className="shrink-0">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
c.user.avatarUrl ||
|
||||||
|
`${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
|
||||||
|
}
|
||||||
|
alt={c.user.username}
|
||||||
|
className={`${isReply ? "w-12 h-12" : "w-14 h-14"} rounded-full object-cover border border-white/10`}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/user/${c.user.username}`}
|
||||||
|
className="text-base font-semibold text-text hover:underline truncate"
|
||||||
|
>
|
||||||
|
{c.user.username}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-text-muted">{timeAgo(c.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMine ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(c.id)}
|
||||||
|
disabled={deletingId === c.id}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 bg-background border border-white/10 text-text-muted hover:text-red-400 hover:border-white/20 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
aria-label="Yorumu sil"
|
||||||
|
title="Sil"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span className="text-xs font-semibold">
|
||||||
|
{deletingId === c.id ? "Siliniyor..." : "Sil"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-lg p-2 bg-background border border-white/10 text-text-muted hover:text-text hover:border-white/20 transition"
|
||||||
|
aria-label="Yorum seçenekleri"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-base text-text leading-relaxed whitespace-pre-line">
|
||||||
|
{c.text}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold bg-background border border-white/10 text-text-muted hover:text-primary hover:border-white/20 transition"
|
||||||
|
>
|
||||||
|
<Heart className="w-4 h-4" />
|
||||||
|
<span>25</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isAuthenticated) return onRequireLogin()
|
||||||
|
setReplyToId(c.id)
|
||||||
|
setReplyText("")
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold bg-background border border-white/10 text-text-muted hover:text-primary hover:border-white/20 transition"
|
||||||
|
>
|
||||||
|
<MessageCircle className="w-4 h-4" />
|
||||||
|
<span>Yanıtla</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{replyToId === c.id && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => onReplySubmit(e, c.id)}
|
||||||
|
className="mt-3 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Yanıt yaz..."
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 rounded-xl border border-white/10 bg-background px-4 py-3 text-sm text-text placeholder:text-text-muted/70 outline-none focus:ring-2 focus:ring-primary/40"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 rounded-xl px-4 py-3 text-sm font-semibold bg-primary text-black hover:bg-primary-hover transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Gönder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setReplyToId(null)
|
||||||
|
setReplyText("")
|
||||||
|
}}
|
||||||
|
className="shrink-0 rounded-xl px-4 py-3 text-sm font-semibold bg-background border border-white/10 text-text-muted hover:border-white/20 transition"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{replies.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="pl-6 border-l border-primary/20">
|
||||||
|
{replies.map((r) => (
|
||||||
|
<CommentRow
|
||||||
|
key={r.id}
|
||||||
|
c={r}
|
||||||
|
isReply
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
isAuthenticated={isAuthenticated}
|
||||||
|
onRequireLogin={onRequireLogin}
|
||||||
|
deletingId={deletingId}
|
||||||
|
onDelete={onDelete}
|
||||||
|
replyToId={replyToId}
|
||||||
|
setReplyToId={setReplyToId}
|
||||||
|
replyText={replyText}
|
||||||
|
setReplyText={setReplyText}
|
||||||
|
onReplySubmit={onReplySubmit}
|
||||||
|
repliesByParent={repliesByParent}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DealComments({ dealId, onRequireLogin }: DealCommentsProps) {
|
export default function DealComments({ dealId, onRequireLogin }: DealCommentsProps) {
|
||||||
const [comments, setComments] = useState<Comment[]>([])
|
const [comments, setComments] = useState<Comment[]>([])
|
||||||
const [newComment, setNewComment] = useState("")
|
const [newComment, setNewComment] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { isAuthenticated } = useAuth()
|
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const [replyToId, setReplyToId] = useState<number | null>(null)
|
||||||
|
const [replyText, setReplyText] = useState("")
|
||||||
|
|
||||||
|
const auth = useAuth()
|
||||||
|
const { isAuthenticated } = auth
|
||||||
|
const currentUserId = (auth as any)?.user?.id ?? (auth as any)?.userId ?? null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadComments() {
|
async function loadComments() {
|
||||||
|
|
@ -29,6 +233,29 @@ export default function DealComments({ dealId, onRequireLogin }: DealCommentsPro
|
||||||
loadComments()
|
loadComments()
|
||||||
}, [dealId])
|
}, [dealId])
|
||||||
|
|
||||||
|
const { roots, repliesByParent } = useMemo(() => {
|
||||||
|
const roots: Comment[] = []
|
||||||
|
const map = new Map<number, Comment[]>()
|
||||||
|
|
||||||
|
for (const c of comments) {
|
||||||
|
const pid = (c as any).parentId ?? null
|
||||||
|
if (pid == null) roots.push(c)
|
||||||
|
else {
|
||||||
|
const arr = map.get(pid) ?? []
|
||||||
|
arr.push(c)
|
||||||
|
map.set(pid, arr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roots.sort((a, b) => Number(new Date(b.createdAt)) - Number(new Date(a.createdAt)))
|
||||||
|
for (const [k, arr] of map.entries()) {
|
||||||
|
arr.sort((a, b) => Number(new Date(b.createdAt)) - Number(new Date(a.createdAt)))
|
||||||
|
map.set(k, arr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { roots, repliesByParent: map }
|
||||||
|
}, [comments])
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!newComment.trim()) return
|
if (!newComment.trim()) return
|
||||||
|
|
@ -36,7 +263,7 @@ export default function DealComments({ dealId, onRequireLogin }: DealCommentsPro
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const added = await postComment(dealId, newComment)
|
const added = await postComment(dealId, newComment, null)
|
||||||
setComments((prev) => [added, ...prev])
|
setComments((prev) => [added, ...prev])
|
||||||
setNewComment("")
|
setNewComment("")
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -47,9 +274,44 @@ export default function DealComments({ dealId, onRequireLogin }: DealCommentsPro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReplySubmit = async (e: React.FormEvent, parentId: number) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!replyText.trim()) return
|
||||||
|
if (!isAuthenticated) return onRequireLogin()
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const added = await postComment(dealId, replyText, parentId)
|
||||||
|
setComments((prev) => [added, ...prev])
|
||||||
|
setReplyText("")
|
||||||
|
setReplyToId(null)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
alert(err.message || "Sunucu hatası")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (commentId: number) => {
|
||||||
|
if (!isAuthenticated) return onRequireLogin()
|
||||||
|
const ok = window.confirm("Yorum silinsin mi?")
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
setDeletingId(commentId)
|
||||||
|
try {
|
||||||
|
await deleteComment(commentId)
|
||||||
|
setComments((prev) => prev.filter((c) => c.id !== commentId))
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
alert(err.message || "Yorum silinemedi")
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-surface border border-white/10 p-5 flex flex-col">
|
<div className="rounded-3xl bg-surface border border-white/10 p-5 flex flex-col">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<h2 className="text-lg font-semibold text-text">Yorumlar</h2>
|
<h2 className="text-lg font-semibold text-text">Yorumlar</h2>
|
||||||
<span className="text-xs text-text-muted bg-background border border-white/10 rounded-full px-3 py-1">
|
<span className="text-xs text-text-muted bg-background border border-white/10 rounded-full px-3 py-1">
|
||||||
|
|
@ -57,84 +319,35 @@ export default function DealComments({ dealId, onRequireLogin }: DealCommentsPro
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List: only desktop scroll */}
|
|
||||||
<div className="mt-4 flex-1 lg:max-h-[calc(100vh-280px)] lg:overflow-y-auto pr-2">
|
<div className="mt-4 flex-1 lg:max-h-[calc(100vh-280px)] lg:overflow-y-auto pr-2">
|
||||||
{comments.length > 0 ? (
|
{roots.length > 0 ? (
|
||||||
<div className="divide-y divide-white/10">
|
<div className="divide-y divide-white/10">
|
||||||
{comments.map((c) => (
|
{roots.map((c) => (
|
||||||
<div key={c.id} className="py-5 first:pt-0 last:pb-0">
|
<CommentRow
|
||||||
<div className="flex gap-3">
|
key={c.id}
|
||||||
<Link to={`/user/${c.user.username}`} className="shrink-0">
|
c={c}
|
||||||
<img
|
currentUserId={currentUserId}
|
||||||
src={
|
isAuthenticated={isAuthenticated}
|
||||||
c.user.avatarUrl ||
|
onRequireLogin={onRequireLogin}
|
||||||
`${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
|
deletingId={deletingId}
|
||||||
}
|
onDelete={handleDelete}
|
||||||
alt={c.user.username}
|
replyToId={replyToId}
|
||||||
className="w-16 h-16 rounded-full object-cover border border-white/10"
|
setReplyToId={setReplyToId}
|
||||||
/>
|
replyText={replyText}
|
||||||
</Link>
|
setReplyText={setReplyText}
|
||||||
|
onReplySubmit={handleReplySubmit}
|
||||||
<div className="min-w-0 flex-1">
|
repliesByParent={repliesByParent}
|
||||||
<div className="flex items-center justify-between gap-3">
|
/>
|
||||||
<div className="min-w-0 flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
to={`/user/${c.user.username}`}
|
|
||||||
className="text-lg font-semibold text-text hover:underline truncate"
|
|
||||||
>
|
|
||||||
{c.user.username}
|
|
||||||
</Link>
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
{timeAgo(c.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center rounded-lg p-2 bg-background border border-white/10 text-text-muted hover:text-text hover:border-white/20 transition"
|
|
||||||
aria-label="Yorum seçenekleri"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-2 text-base text-text leading-relaxed whitespace-pre-line">
|
|
||||||
{c.text}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold bg-background border border-white/10 text-text-muted hover:text-primary hover:border-white/20 transition"
|
|
||||||
>
|
|
||||||
<Heart className="w-4 h-4" />
|
|
||||||
<span>25</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold bg-background border border-white/10 text-text-muted hover:text-primary hover:border-white/20 transition"
|
|
||||||
>
|
|
||||||
<MessageCircle className="w-4 h-4" />
|
|
||||||
<span>Yanıtla</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl bg-background border border-white/10 p-4 text-center">
|
<div className="rounded-2xl bg-background border border-white/10 p-4 text-center">
|
||||||
<div className="text-sm font-semibold text-text">Henüz yorum yok</div>
|
<div className="text-sm font-semibold text-text">Henüz yorum yok</div>
|
||||||
<div className="text-xs text-text-muted mt-1">
|
<div className="text-xs text-text-muted mt-1">İlk yorumu sen yazabilirsin.</div>
|
||||||
İlk yorumu sen yazabilirsin.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer (always visible) */}
|
|
||||||
<div className="mt-5 pt-5 border-t border-white/10">
|
<div className="mt-5 pt-5 border-t border-white/10">
|
||||||
{isAuthenticated ? (
|
{isAuthenticated ? (
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useMemo } from "react"
|
import React, { useMemo } from "react"
|
||||||
import { ExternalLink, Copy } from "lucide-react"
|
import { ExternalLink, Copy } from "lucide-react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
|
|
||||||
type DealDetailsProps = {
|
type DealDetailsProps = {
|
||||||
title: string
|
title: string
|
||||||
price: string
|
price: string
|
||||||
|
|
@ -68,7 +69,7 @@ export default function DealDetails({
|
||||||
{/* Meta: posted by + time */}
|
{/* Meta: posted by + time */}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">
|
||||||
<span>
|
<span>
|
||||||
Paylaşan{" "}
|
|
||||||
{postedBy ? (
|
{postedBy ? (
|
||||||
<Link
|
<Link
|
||||||
to={`/user/${postedBy}`}
|
to={`/user/${postedBy}`}
|
||||||
|
|
@ -80,6 +81,7 @@ export default function DealDetails({
|
||||||
) : (
|
) : (
|
||||||
<span className="text-text font-semibold">-</span>
|
<span className="text-text font-semibold">-</span>
|
||||||
)}
|
)}
|
||||||
|
{" "} paylaştı.
|
||||||
</span>
|
</span>
|
||||||
<span className="opacity-60">•</span>
|
<span className="opacity-60">•</span>
|
||||||
<span>{timeAgo ? `${timeAgo} önce` : "-"}</span>
|
<span>{timeAgo ? `${timeAgo} önce` : "-"}</span>
|
||||||
|
|
@ -89,7 +91,7 @@ export default function DealDetails({
|
||||||
<div className="mt-5 flex items-end justify-between gap-4">
|
<div className="mt-5 flex items-end justify-between gap-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-s text-text-muted">Fiyat</span>
|
<span className="text-s text-text-muted">Fiyat</span>
|
||||||
<span className="mt-1 text-3xl font-extrabold text-primary tracking-tight">
|
<span className="mt-1 text-3xl text-primary tracking-tight">
|
||||||
{price} ₺
|
{price} ₺
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,14 +4,15 @@ import type { DealImage } from "../../models/deal/DealImage"
|
||||||
type DealImagesProps = {
|
type DealImagesProps = {
|
||||||
images: DealImage[]
|
images: DealImage[]
|
||||||
alt?: string
|
alt?: string
|
||||||
|
isExpired?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DealImages({ images, alt }: DealImagesProps) {
|
export default function DealImages({ images, alt, isExpired = false }: DealImagesProps) {
|
||||||
const srcs = useMemo(() => {
|
const srcs = useMemo(() => {
|
||||||
return (images ?? [])
|
return (images ?? [])
|
||||||
.filter((x) => x?.imageUrl && x.imageUrl.trim().length > 0)
|
.filter((x) => x?.imageUrl && x.imageUrl.trim().length > 0)
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.slice(0, 5) // max 5
|
.slice(0, 5)
|
||||||
.map((x) => x.imageUrl.trim())
|
.map((x) => x.imageUrl.trim())
|
||||||
}, [images])
|
}, [images])
|
||||||
|
|
||||||
|
|
@ -43,16 +44,21 @@ export default function DealImages({ images, alt }: DealImagesProps) {
|
||||||
const safeIndex = Math.min(activeIndex, srcs.length - 1)
|
const safeIndex = Math.min(activeIndex, srcs.length - 1)
|
||||||
const activeSrc = srcs[safeIndex]
|
const activeSrc = srcs[safeIndex]
|
||||||
|
|
||||||
|
const expiredClass = isExpired ? "grayscale opacity-90" : ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{/* Main image (no crop) */}
|
{/* Main image */}
|
||||||
<div className="rounded-3xl bg-background overflow-hidden">
|
<div className="rounded-t-2xl bg-background overflow-hidden">
|
||||||
<div className="relative w-full aspect-[4/3] bg-surface">
|
<div className="relative w-full aspect-[4/3] bg-surface">
|
||||||
{!imgError ? (
|
{!imgError ? (
|
||||||
<img
|
<img
|
||||||
src={activeSrc}
|
src={activeSrc}
|
||||||
alt={alt ?? "deal image"}
|
alt={alt ?? "deal image"}
|
||||||
className="absolute inset-0 w-full h-full object-contain"
|
className={[
|
||||||
|
"absolute inset-0 w-full h-full object-contain transition",
|
||||||
|
expiredClass,
|
||||||
|
].join(" ")}
|
||||||
onError={() => setImgError(true)}
|
onError={() => setImgError(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -60,24 +66,19 @@ export default function DealImages({ images, alt }: DealImagesProps) {
|
||||||
<div className="w-12 h-12 rounded-2xl bg-surface border border-white/10 flex items-center justify-center text-text">
|
<div className="w-12 h-12 rounded-2xl bg-surface border border-white/10 flex items-center justify-center text-text">
|
||||||
🖼️
|
🖼️
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-semibold text-text">
|
<div className="text-sm font-semibold text-text">Görsel yüklenemedi</div>
|
||||||
Görsel yüklenemedi
|
<div className="text-xs text-text-muted">Daha sonra tekrar deneyin.</div>
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-muted">
|
|
||||||
Daha sonra tekrar deneyin.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Thumbnails (crop ok for selection) */}
|
{/* Thumbnails */}
|
||||||
{srcs.length > 1 ? (
|
{srcs.length > 1 ? (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{/* full-width top divider */}
|
|
||||||
<div className="h-px w-full bg-white/10" />
|
<div className="h-px w-full bg-white/10" />
|
||||||
|
|
||||||
<div className="mt-3 flex gap-3 overflow-x-auto pb-1">
|
<div className="mt-3 flex gap-3 pb-5 pl-5 overflow-x-auto pb-1">
|
||||||
{srcs.map((src, idx) => {
|
{srcs.map((src, idx) => {
|
||||||
const active = idx === safeIndex
|
const active = idx === safeIndex
|
||||||
return (
|
return (
|
||||||
|
|
@ -98,8 +99,7 @@ export default function DealImages({ images, alt }: DealImagesProps) {
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={`thumb ${idx + 1}`}
|
alt={`thumb ${idx + 1}`}
|
||||||
className="w-full h-full object-cover"
|
className={["w-full h-full object-cover transition", expiredClass].join(" ")}
|
||||||
onError={() => {}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -108,7 +108,6 @@ export default function DealImages({ images, alt }: DealImagesProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
src/components/DealDetails/DealNotice.tsx
Normal file
81
src/components/DealDetails/DealNotice.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// src/components/DealDetails/DealNotice.tsx
|
||||||
|
import type { DealNoticeSeverity } from "../../models/deal/DealDetail"
|
||||||
|
|
||||||
|
type DealNoticeProps = {
|
||||||
|
title: string
|
||||||
|
body?: string | null
|
||||||
|
severity: DealNoticeSeverity
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<
|
||||||
|
DealNoticeSeverity,
|
||||||
|
{ wrap: string; dotVar: string; titleVar: string; bodyVar: string }
|
||||||
|
> = {
|
||||||
|
INFO: {
|
||||||
|
wrap:
|
||||||
|
"border-[color-mix(in_srgb,var(--color-notice-info)_35%,transparent)] " +
|
||||||
|
"bg-[linear-gradient(90deg,var(--color-notice-info-soft),var(--color-surface))] " +
|
||||||
|
"shadow-[0_0_0_1px_color-mix(in_srgb,var(--color-notice-info)_18%,transparent)]",
|
||||||
|
dotVar: "var(--color-notice-info)",
|
||||||
|
titleVar: "var(--color-text)",
|
||||||
|
bodyVar: "var(--color-text-muted)",
|
||||||
|
},
|
||||||
|
SUCCESS: {
|
||||||
|
wrap:
|
||||||
|
"border-[color-mix(in_srgb,var(--color-notice-success)_35%,transparent)] " +
|
||||||
|
"bg-[linear-gradient(90deg,var(--color-notice-success-soft),var(--color-surface))] " +
|
||||||
|
"shadow-[0_0_0_1px_color-mix(in_srgb,var(--color-notice-success)_18%,transparent)]",
|
||||||
|
dotVar: "var(--color-notice-success)",
|
||||||
|
titleVar: "var(--color-text)",
|
||||||
|
bodyVar: "var(--color-text-muted)",
|
||||||
|
},
|
||||||
|
WARNING: {
|
||||||
|
wrap:
|
||||||
|
"border-[color-mix(in_srgb,var(--color-notice-warning)_35%,transparent)] " +
|
||||||
|
"bg-[linear-gradient(90deg,var(--color-notice-warning-soft),var(--color-surface))] " +
|
||||||
|
"shadow-[0_0_0_1px_color-mix(in_srgb,var(--color-notice-warning)_18%,transparent)]",
|
||||||
|
dotVar: "var(--color-notice-warning)",
|
||||||
|
titleVar: "var(--color-text)",
|
||||||
|
bodyVar: "var(--color-text-muted)",
|
||||||
|
},
|
||||||
|
DANGER: {
|
||||||
|
wrap:
|
||||||
|
"border-[color-mix(in_srgb,var(--color-notice-danger)_35%,transparent)] " +
|
||||||
|
"bg-[linear-gradient(90deg,var(--color-notice-danger-soft),var(--color-surface))] " +
|
||||||
|
"shadow-[0_0_0_1px_color-mix(in_srgb,var(--color-notice-danger)_18%,transparent)]",
|
||||||
|
dotVar: "var(--color-notice-danger)",
|
||||||
|
titleVar: "var(--color-text)",
|
||||||
|
bodyVar: "var(--color-text-muted)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DealNotice({ title, body, severity }: DealNoticeProps) {
|
||||||
|
const s = styles[severity] ?? styles.INFO
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-2xl border p-4 sm:p-5 ${s.wrap}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span
|
||||||
|
className="mt-1.5 h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: s.dotVar }}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
className="text-sm sm:text-base font-semibold leading-snug"
|
||||||
|
style={{ color: s.titleVar }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
{body ? (
|
||||||
|
<div
|
||||||
|
className="text-sm mt-1 whitespace-pre-wrap"
|
||||||
|
style={{ color: s.bodyVar }}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
src/components/DealDetails/SimilarDeals.tsx
Normal file
98
src/components/DealDetails/SimilarDeals.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { scoreToHeat } from "../../utils/heat"
|
||||||
|
import type { SimilarDeal } from "../../models/Shared/SimilarDeal"
|
||||||
|
|
||||||
|
type SimilarDealsProps = {
|
||||||
|
deals: SimilarDeal[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTryPrice(p?: number) {
|
||||||
|
if (typeof p !== "number") return null
|
||||||
|
return `${p.toLocaleString("tr-TR")}₺`
|
||||||
|
}
|
||||||
|
export default function SimilarDeals({ deals }: SimilarDealsProps) {
|
||||||
|
if (!deals || deals.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-3xl bg-surface border border-white/10 p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-text">Benzer fırsatlar</h3>
|
||||||
|
<Link to="/" className="text-xs text-text-muted hover:text-text transition">
|
||||||
|
Tümünü gör
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
-mx-4 px-4
|
||||||
|
flex gap-3 overflow-x-auto pb-1
|
||||||
|
snap-x snap-mandatory
|
||||||
|
md:mx-0 md:px-0 md:overflow-x-hidden md:pb-0 md:snap-none
|
||||||
|
md:grid md:grid-cols-5 md:gap-3
|
||||||
|
[scrollbar-width:none] [-ms-overflow-style:none]
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{deals.slice(0, 5).map((d) => {
|
||||||
|
const { degree, color } = scoreToHeat(d.score ?? 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={d.id}
|
||||||
|
to={`/deal/${d.id}`}
|
||||||
|
className="
|
||||||
|
snap-start
|
||||||
|
min-w-[240px] max-w-[240px]
|
||||||
|
md:min-w-0 md:max-w-none md:w-full
|
||||||
|
rounded-2xl border border-white/10
|
||||||
|
bg-background/30 hover:bg-background/40 hover:border-white/20
|
||||||
|
transition
|
||||||
|
overflow-hidden
|
||||||
|
flex-shrink-0
|
||||||
|
flex flex-col
|
||||||
|
min-h-[350px] // Minimum height to ensure consistent sizing
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{/* Image Section */}
|
||||||
|
<div className="relative w-full bg-background border-b border-white/10 overflow-hidden flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={d.imageUrl || "/placeholder.png"}
|
||||||
|
alt={d.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<span
|
||||||
|
style={{ color }}
|
||||||
|
className="inline-flex items-center px-2 py-1 text-[12px] font-extrabold leading-none tracking-wide"
|
||||||
|
aria-label={`Sıcaklık ${degree}°`}
|
||||||
|
title={`Sıcaklık: ${degree}°`}
|
||||||
|
>
|
||||||
|
{degree}°
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Section */}
|
||||||
|
<div className="p-3 flex flex-col flex-grow relative">
|
||||||
|
{/* Title - Always 2 lines max */}
|
||||||
|
<div className="text-sm font-semibold text-text line-clamp-2 overflow-hidden text-ellipsis hover:text-primary transition min-h-[2.2em]">
|
||||||
|
{d.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seller and Price */}
|
||||||
|
<div className="absolute bottom-3 left-3 flex flex-col space-y-1">
|
||||||
|
{/* Seller Name */}
|
||||||
|
<span className="text-xs text-text-muted truncate">{d.sellerName ?? "Bilinmiyor"}</span>
|
||||||
|
{/* Price */}
|
||||||
|
{typeof d.price === "number" && (
|
||||||
|
<div className="text-primary font-semibold">{formatTryPrice(d.price)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/components/FilterMenu.tsx
Normal file
78
src/components/FilterMenu.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// src/components/FilterMenu.tsx
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
type FilterMenuProps = {
|
||||||
|
onFilterChange: (filters: { [key: string]: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FilterMenu: React.FC<FilterMenuProps> = ({ onFilterChange }) => {
|
||||||
|
const [filtersVisible, setFiltersVisible] = useState(false);
|
||||||
|
const [selectedFilters, setSelectedFilters] = useState<{ [key: string]: boolean }>({
|
||||||
|
filter1: false,
|
||||||
|
filter2: false,
|
||||||
|
filter3: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleFilterMenu = () => {
|
||||||
|
setFiltersVisible(!filtersVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, checked } = e.target;
|
||||||
|
const updatedFilters = { ...selectedFilters, [name]: checked };
|
||||||
|
setSelectedFilters(updatedFilters);
|
||||||
|
onFilterChange(updatedFilters); // Pass the updated filters to parent
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block text-left">
|
||||||
|
{/* Filtreler Butonu */}
|
||||||
|
<button
|
||||||
|
onClick={toggleFilterMenu}
|
||||||
|
className="px-6 py-2 text-m font-bold text-text-muted bg-surface-2 rounded-full transition-colors hover:bg-background/60 focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
>
|
||||||
|
Filtreler
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Filtre Menüsü */}
|
||||||
|
{filtersVisible && (
|
||||||
|
<div className="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-10">
|
||||||
|
<div className="p-4">
|
||||||
|
<label className="block text-sm text-text-muted mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="filter1"
|
||||||
|
checked={selectedFilters.filter1}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
Fiyat Aralığı
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm text-text-muted mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="filter2"
|
||||||
|
checked={selectedFilters.filter2}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
Yüksek Puan
|
||||||
|
</label>
|
||||||
|
<label className="block text-sm text-text-muted mb-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="filter3"
|
||||||
|
checked={selectedFilters.filter3}
|
||||||
|
onChange={handleCheckboxChange}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
Yeni Eklenenler
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterMenu;
|
||||||
51
src/components/Layout/AppShell.tsx
Normal file
51
src/components/Layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom"
|
||||||
|
import Navbar from "./Navbar/Navbar"
|
||||||
|
import CategoriesSidebar from "../Categories/CategoriesSidebar"
|
||||||
|
import { DUMMY_CATEGORIES } from "../../data/categories"
|
||||||
|
|
||||||
|
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const [catsOpen, setCatsOpen] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const activeSlug = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
return params.get("cat")
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
|
// route değişince sidebar kapansın
|
||||||
|
useEffect(() => {
|
||||||
|
setCatsOpen(false)
|
||||||
|
}, [location.pathname, location.search])
|
||||||
|
|
||||||
|
const setCategory = (slug: string) => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
params.set("cat", slug)
|
||||||
|
navigate({ pathname: location.pathname, search: params.toString() }, { replace: false })
|
||||||
|
}
|
||||||
|
console.log("APPSHELL render, catsOpen:", catsOpen)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Navbar
|
||||||
|
onToggleCategories={() => {
|
||||||
|
console.log("toggle in shell")
|
||||||
|
setCatsOpen((v) => !v)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<CategoriesSidebar
|
||||||
|
isOpen={catsOpen}
|
||||||
|
categories={DUMMY_CATEGORIES}
|
||||||
|
activeSlug={activeSlug}
|
||||||
|
onClose={() => setCatsOpen(false)}
|
||||||
|
onSelect={setCategory}
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
<main className="max-w-6xl mx-auto px-4 py-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,43 @@
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="bg-surface border-t border-zinc-800">
|
<footer className="bg-surface border-t border-zinc-800 py-8">
|
||||||
<div className="max-w-6xl mx-auto px-6 py-4 text-center text-sm text-text-muted">
|
<div className="max-w-6xl mx-auto px-6 text-center text-sm text-text-muted">
|
||||||
© {new Date().getFullYear()} DealHeat. All rights reserved.
|
{/* Footer üst kısmı: Logo ve sosyal medya bağlantıları */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex justify-center space-x-6 mb-4">
|
||||||
|
{/* Sosyal medya bağlantıları */}
|
||||||
|
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-primary transition">
|
||||||
|
<i className="fab fa-twitter"></i> {/* Twitter */}
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-primary transition">
|
||||||
|
<i className="fab fa-github"></i> {/* GitHub */}
|
||||||
|
</a>
|
||||||
|
<a href="https://linkedin.com" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-primary transition">
|
||||||
|
<i className="fab fa-linkedin"></i> {/* LinkedIn */}
|
||||||
|
</a>
|
||||||
|
<a href="https://facebook.com" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-primary transition">
|
||||||
|
<i className="fab fa-facebook"></i> {/* Facebook */}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Şirket ismi ve logo */}
|
||||||
|
<div className="text-lg font-semibold text-text mb-4">
|
||||||
|
<span className="font-bold text-primary">DealHeat</span> {/* Logo / Branding */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer alt kısmı: Bağlantılar ve telif hakkı */}
|
||||||
|
<div className="flex justify-center space-x-8 mb-4">
|
||||||
|
<a href="/privacy-policy" className="text-gray-400 hover:text-text-muted transition">Gizlilik Politikası</a>
|
||||||
|
<a href="/terms-of-service" className="text-gray-400 hover:text-text-muted transition">Kullanım Şartları</a>
|
||||||
|
<a href="/about-us" className="text-gray-400 hover:text-text-muted transition">Hakkımızda</a>
|
||||||
|
<a href="/contact" className="text-gray-400 hover:text-text-muted transition">İletişim</a> {/* İletişim linki ekledim */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telif Hakkı */}
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
© {new Date().getFullYear()} <span className="font-semibold">DealHeat</span>. Tüm hakları saklıdır.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import UserInfo from "./UserInfo"
|
import Notifications from "./Notifications"; // Notification componentini import ediyoruz
|
||||||
import { Link } from "react-router-dom"
|
import UserInfo from "./UserInfo";
|
||||||
import SearchBar from "../Navbar/SearchBar"
|
import { Link } from "react-router-dom";
|
||||||
import ThemeToggle from "./ThemeToggle"
|
import SearchBar from "../Navbar/SearchBar";
|
||||||
import { useHideOnScroll } from "../../../hooks/useHideOnScroll"
|
import { useHideOnScroll } from "../../../hooks/useHideOnScroll";
|
||||||
|
|
||||||
|
type NavbarProps = {
|
||||||
|
onToggleCategories: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Navbar({ onToggleCategories }: NavbarProps) {
|
||||||
|
console.log("NAVBAR received prop:", onToggleCategories);
|
||||||
|
const visible = useHideOnScroll({
|
||||||
|
topThreshold: 12,
|
||||||
|
hideAfterDownPx: 12,
|
||||||
|
revealAfterUpPx: 150,
|
||||||
|
});
|
||||||
|
|
||||||
export default function Navbar() {
|
|
||||||
const visible = useHideOnScroll({
|
|
||||||
topThreshold: 12,
|
|
||||||
hideAfterDownPx: 12,
|
|
||||||
revealAfterUpPx: 150, // artır: daha geç gelsin
|
|
||||||
})
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={[
|
className={[
|
||||||
|
|
@ -19,37 +25,26 @@ const visible = useHideOnScroll({
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<nav className="bg-surface border-b border-border">
|
<nav className="bg-surface border-b border-border">
|
||||||
<div className="mx-auto flex justify-between items-center px-6 py-3">
|
<div className="max-w-[1400px] mx-auto px-4 py-7 flex justify-between items-center">
|
||||||
{/* Sol: logo + menü */}
|
{/* Sol: logo + menü */}
|
||||||
<div className="flex items-center gap-10">
|
<div className="flex items-center gap-10">
|
||||||
<Link to="/" className="text-primary font-bold text-xl">
|
<Link to="/" className="text-primary font-bold text-3xl">
|
||||||
DealHeat
|
DealHeat
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<ul className="flex gap-6 text-text items-center">
|
<ul className="flex gap-6 text-text items-center">
|
||||||
|
{/* KATEGORİLER */}
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<button
|
||||||
to="/"
|
type="button"
|
||||||
className="hover:text-primary transition-colors font-semibold"
|
onClick={() => {
|
||||||
|
console.log("toggle categories");
|
||||||
|
onToggleCategories();
|
||||||
|
}}
|
||||||
|
className="hover:text-primary text-grey transition-colors font-semibold cursor-pointer"
|
||||||
>
|
>
|
||||||
Anasayfa
|
Kategoriler
|
||||||
</Link>
|
</button>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
to="/deals"
|
|
||||||
className="hover:text-primary transition-colors font-semibold"
|
|
||||||
>
|
|
||||||
Fırsatlar
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
to="/contact"
|
|
||||||
className="hover:text-primary transition-colors font-semibold"
|
|
||||||
>
|
|
||||||
İletişim
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,10 +54,14 @@ const visible = useHideOnScroll({
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sağ: toggle + kullanıcı + buton */}
|
{/* Sağ: notification + kullanıcı + buton */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-10">
|
||||||
<ThemeToggle />
|
{/* Notification Icon */}
|
||||||
|
<Notifications />
|
||||||
|
|
||||||
|
{/* User Info Component */}
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/create-deal"
|
to="/create-deal"
|
||||||
className="bg-primary hover:bg-primary-hover text-white font-semibold px-4 py-2 rounded-md transition"
|
className="bg-primary hover:bg-primary-hover text-white font-semibold px-4 py-2 rounded-md transition"
|
||||||
|
|
@ -73,5 +72,5 @@ const visible = useHideOnScroll({
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
src/components/Layout/Navbar/Notifications.tsx
Normal file
47
src/components/Layout/Navbar/Notifications.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Bell } from "lucide-react"; // lucide-react'ten Bell iconunu import ediyoruz
|
||||||
|
|
||||||
|
export default function Notifications() {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
// Son 5 notification
|
||||||
|
const notifications = [
|
||||||
|
"Yeni mesaj aldınız.",
|
||||||
|
"Profiliniz güncellendi.",
|
||||||
|
"Yeni takipçi eklediniz.",
|
||||||
|
"Yorumunuzu beğendiler.",
|
||||||
|
"Yeni bir yorum yazıldı.",
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
{/* Notification Icon (🔔) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||||
|
className="text-2xl hover:text-primary transition"
|
||||||
|
>
|
||||||
|
<Bell size={24} className="text-grey" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Notification Dropdown */}
|
||||||
|
{isDropdownOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-64 bg-white text-black rounded-lg shadow-lg p-4">
|
||||||
|
{/* Son 5 notification */}
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{notifications.slice(0, 5).map((notification, index) => (
|
||||||
|
<li key={index} className="text-sm">{notification}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Daha Fazla Butonu */}
|
||||||
|
<button
|
||||||
|
onClick={() => console.log("Daha fazla notification gösterilecek!")}
|
||||||
|
className="mt-3 text-sm text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Daha Fazla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,11 +21,11 @@ export default function SearchBar() {
|
||||||
placeholder="Fırsat ara..."
|
placeholder="Fırsat ara..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="flex-1 px-4 py-2 bg-transparent text-text placeholder:text-text-muted focus:outline-none"
|
className="flex-1 px-4 py-2 bg-transparent text-grey placeholder:text-text-muted focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-primary hover:bg-primary-hover text-text font-semibold px-4 py-2 transition"
|
className="hover:bg-primary-hover bg-grey text-white font-semibold px-4 py-2 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
Ara
|
Ara
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { useAuth } from "../../../context/AuthContext"
|
import { useAuth } from "../../../context/AuthContext"
|
||||||
import LoginModal from "../../Auth/LoginModal"
|
import LoginModal from "../../Auth/LoginModal"
|
||||||
|
import ThemeToggle from "./ThemeToggle"
|
||||||
|
|
||||||
export default function UserInfo() {
|
export default function UserInfo() {
|
||||||
const { user, isAuthenticated, logout } = useAuth()
|
const { user, isAuthenticated, logout } = useAuth()
|
||||||
|
|
@ -53,35 +54,39 @@ export default function UserInfo() {
|
||||||
user?.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
|
user?.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex items-center gap-3" ref={menuRef}>
|
<div className="relative flex items-center" ref={menuRef}>
|
||||||
{isAuthenticated && user ? (
|
{isAuthenticated && user ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMenuOpen((prev) => !prev)}
|
onClick={() => setMenuOpen((prev) => !prev)}
|
||||||
className={`rounded-full focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)] ${
|
className={[
|
||||||
menuOpen ? "ring-2 ring-[var(--color-primary-ring)]" : ""
|
"rounded-full cursor-pointer focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)]",
|
||||||
}`}
|
menuOpen ? "ring-2 ring-[var(--color-primary-ring)]" : "",
|
||||||
|
].join(" ")}
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={menuOpen}
|
aria-expanded={menuOpen}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={avatarSrc}
|
src={avatarSrc}
|
||||||
alt={user.username}
|
alt={user.username}
|
||||||
className="w-8 h-8 rounded-full border border-border hover:border-[var(--color-primary)] transition"
|
className="w-10 h-10 rounded-full border border-border hover:border-[var(--color-primary)] transition"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 mt-2 w-56 bg-surface border border-border rounded-xl shadow-lg z-50 overflow-hidden"
|
className={[
|
||||||
|
"absolute left-1/2 -translate-x-1/2 top-full mt-2",
|
||||||
|
"w-52 bg-surface border border-border rounded-xl shadow-lg z-50 overflow-hidden ",
|
||||||
|
].join(" ")}
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
{/* Entire row clickable */}
|
{/* Profile header */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToProfile}
|
onClick={goToProfile}
|
||||||
className="w-full text-left px-4 py-3 border-b border-border hover:bg-surface-2 transition"
|
className="w-full text-center px-4 py-3 border-b border-border hover:bg-surface-2 cursor-pointer transition"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<span className="text-sm text-text font-semibold leading-tight">
|
<span className="text-sm text-text font-semibold leading-tight">
|
||||||
|
|
@ -89,11 +94,19 @@ export default function UserInfo() {
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Theme toggle row (keep as-is) */}
|
||||||
|
<div className="px-4 py-3 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-text">Tema</span>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={goToAccount}
|
onClick={goToAccount}
|
||||||
className="w-full text-left px-4 py-2.5 text-sm text-text hover:bg-surface-2 transition"
|
className="w-full cursor-pointer text-center px-4 py-2.5 text-sm text-text hover:bg-surface-2 transition"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
Ayarlar
|
Ayarlar
|
||||||
|
|
@ -102,7 +115,7 @@ export default function UserInfo() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="w-full text-left px-4 py-2.5 text-sm text-danger hover:bg-surface-2 transition"
|
className="w-full text-center cursor-pointer px-4 py-2.5 text-sm text-danger hover:bg-surface-2 transition"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
Çıkış yap
|
Çıkış yap
|
||||||
|
|
@ -112,15 +125,13 @@ export default function UserInfo() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={() => setShowModal(true)}
|
||||||
onClick={() => setShowModal(true)}
|
className="bg-[var(--color-primary)] text-white px-4 py-2 rounded-lg hover:bg-[var(--color-primary-hover)] transition-colors"
|
||||||
className="bg-[var(--color-primary)] text-white px-4 py-2 rounded-lg hover:bg-[var(--color-primary-hover)] transition-colors"
|
>
|
||||||
>
|
Giriş yap
|
||||||
Giriş yap
|
</button>
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showModal && <LoginModal onClose={() => setShowModal(false)} />}
|
{showModal && <LoginModal onClose={() => setShowModal(false)} />}
|
||||||
|
|
|
||||||
78
src/components/Shared/Breadcrumbs.tsx
Normal file
78
src/components/Shared/Breadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// src/components/Shared/Breadcrumbs.tsx
|
||||||
|
import React from "react"
|
||||||
|
import { Link } from "react-router-dom"
|
||||||
|
import { ChevronRight, Home } from "lucide-react"
|
||||||
|
|
||||||
|
type BreadcrumbItem = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Breadcrumbs({
|
||||||
|
breadcrumb = [],
|
||||||
|
currentLabel,
|
||||||
|
showHome = true,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
breadcrumb?: BreadcrumbItem[]
|
||||||
|
currentLabel?: string
|
||||||
|
showHome?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const items = Array.isArray(breadcrumb) ? breadcrumb : []
|
||||||
|
if (!showHome && items.length === 0 && !currentLabel) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
className={["w-full border-b border-white/5 bg-background/40 backdrop-blur", className].join(
|
||||||
|
" "
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="max-w-[1400px] mx-auto px-4 py-2">
|
||||||
|
<ol className="flex items-center gap-1 text-sm text-text-muted overflow-x-auto whitespace-nowrap">
|
||||||
|
{showHome && (
|
||||||
|
<li className="flex items-center">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg hover:bg-white/5 hover:text-text transition"
|
||||||
|
>
|
||||||
|
<Home className="w-4 h-4" />
|
||||||
|
<span>Anasayfa</span>
|
||||||
|
</Link>
|
||||||
|
<ChevronRight className="w-4 h-4 opacity-60" />
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map((c, idx) => {
|
||||||
|
const isLastFromBreadcrumb = idx === items.length - 1 && !currentLabel
|
||||||
|
const to = `/category/${c.slug}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={c.id ?? `${c.slug}-${idx}`} className="flex items-center">
|
||||||
|
{!isLastFromBreadcrumb ? (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="px-2 py-1 rounded-lg hover:bg-white/5 hover:text-text transition"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 text-text font-semibold">{c.name}</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight className="w-4 h-4 opacity-60" />
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{currentLabel && (
|
||||||
|
<li className="flex items-center">
|
||||||
|
<span className="px-2 py-1 text-text font-semibold">{currentLabel}</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,50 +1,47 @@
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, useMemo } from "react"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { voteDeal } from "../../api/deal/voteDeal"
|
import { voteDeal } from "../../api/deal/voteDeal"
|
||||||
import { useAuth } from "../../hooks/useAuth"
|
import { useAuth } from "../../context/AuthContext"
|
||||||
import { ChevronUp, ChevronDown, MessageCircle, Share2 } from "lucide-react"
|
|
||||||
|
|
||||||
type DealCardProps = {
|
import { ChevronUp, ChevronDown, MessageCircle, Share2 } from "lucide-react"
|
||||||
id: number
|
import { timeAgo } from "../../utils/timeAgo"
|
||||||
image: string
|
import { scoreToHeat } from "../../utils/heat"
|
||||||
title: string
|
import type { DealCard } from "../../models/deal/DealCard"
|
||||||
price: string
|
|
||||||
store: string
|
type DealCardMainProps = {
|
||||||
postedBy: string
|
deal: DealCard
|
||||||
score: number
|
|
||||||
comments: number
|
|
||||||
postedAgo: string
|
|
||||||
myVote: 1 | 0 | -1
|
|
||||||
onRequireLogin: () => void
|
onRequireLogin: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DealCardMain({
|
export default function DealCardMain({ deal, onRequireLogin }: DealCardMainProps) {
|
||||||
id,
|
const navigate = useNavigate()
|
||||||
image,
|
const { token, isAuthenticated } = useAuth()
|
||||||
title,
|
|
||||||
price,
|
const id = deal.id
|
||||||
store,
|
const image = deal.imageUrl || "/placeholder.png"
|
||||||
postedBy,
|
const title = deal.title
|
||||||
score,
|
const store = deal.seller?.name ?? "Bilinmiyor"
|
||||||
comments,
|
const postedBy = deal.user?.username ?? "unknown"
|
||||||
postedAgo,
|
const postedAgo = useMemo(() => timeAgo(deal.createdAt), [deal.createdAt])
|
||||||
myVote,
|
const priceText = deal.price ? `${deal.price}₺` : ""
|
||||||
onRequireLogin,
|
const comments = deal.commentsCount
|
||||||
}: DealCardProps) {
|
|
||||||
const [currentScore, setCurrentScore] = useState<number>(score)
|
const isPending = deal.status === "PENDING"
|
||||||
const [currentVote, setCurrentVote] = useState<1 | 0 | -1>(myVote)
|
const isRejected = deal.status === "REJECTED"
|
||||||
|
const showVotes = !(isPending || isRejected)
|
||||||
|
|
||||||
|
const hasUrl = !isRejected && typeof deal.url === "string" && deal.url.trim().length > 0
|
||||||
|
|
||||||
|
const [currentScore, setCurrentScore] = useState<number>(deal.score)
|
||||||
|
const [currentVote, setCurrentVote] = useState<1 | 0 | -1>(deal.myVote)
|
||||||
const [voting, setVoting] = useState(false)
|
const [voting, setVoting] = useState(false)
|
||||||
|
|
||||||
const navigate = useNavigate()
|
useEffect(() => setCurrentScore(deal.score), [deal.score])
|
||||||
const auth = useAuth()
|
useEffect(() => setCurrentVote(deal.myVote), [deal.myVote])
|
||||||
|
|
||||||
useEffect(() => setCurrentScore(score), [score])
|
|
||||||
useEffect(() => setCurrentVote(myVote), [myVote])
|
|
||||||
|
|
||||||
const handleVote = async (e: React.MouseEvent, nextVote: 1 | 0 | -1) => {
|
const handleVote = async (e: React.MouseEvent, nextVote: 1 | 0 | -1) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
const { token, isAuthenticated } = auth
|
|
||||||
if (!isAuthenticated || !token) {
|
if (!isAuthenticated || !token) {
|
||||||
onRequireLogin()
|
onRequireLogin()
|
||||||
return
|
return
|
||||||
|
|
@ -66,7 +63,6 @@ export default function DealCardMain({
|
||||||
} else {
|
} else {
|
||||||
setCurrentVote(prevVote)
|
setCurrentVote(prevVote)
|
||||||
setCurrentScore(prevScore)
|
setCurrentScore(prevScore)
|
||||||
alert(data.error || "Oy gönderilemedi")
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setCurrentVote(prevVote)
|
setCurrentVote(prevVote)
|
||||||
|
|
@ -89,109 +85,175 @@ export default function DealCardMain({
|
||||||
|
|
||||||
const handleCardClick = () => navigate(`/deal/${id}`)
|
const handleCardClick = () => navigate(`/deal/${id}`)
|
||||||
|
|
||||||
return (
|
const handleGoToDeal = (e: React.MouseEvent) => {
|
||||||
<div
|
e.stopPropagation()
|
||||||
onClick={handleCardClick}
|
if (isRejected) return
|
||||||
className="flex gap-4 bg-surface p-4 rounded-2xl border border-white/10 hover:border-white/20 hover:shadow-md transition mb-5 cursor-pointer"
|
if (!hasUrl) return
|
||||||
>
|
window.open(deal.url as string, "_blank", "noopener,noreferrer")
|
||||||
{/* Image */}
|
}
|
||||||
<div className="w-40 h-40 flex-shrink-0 rounded-xl bg-background border border-white/10 overflow-hidden">
|
|
||||||
<img
|
// Using scoreToHeat function to get the color for the score
|
||||||
src={image}
|
const { color } = scoreToHeat(currentScore)
|
||||||
alt={title}
|
|
||||||
className="w-full h-full object-contain"
|
return ( <div
|
||||||
/>
|
onClick={handleCardClick}
|
||||||
|
className={[ "relative flex gap-4 bg-surface rounded border border-white/10",
|
||||||
|
"hover:border-white/20 transition-shadow duration-300 cursor-pointer",
|
||||||
|
"shadow-sm hover:shadow-lg", // Reduced shadow on default, and slightly larger on hover
|
||||||
|
"h-auto", // Yüksekliği otomatik yapıyoruz, resme ve içeriğe göre
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isPending && (
|
||||||
|
<div className="absolute top-3 right-3 z-10">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold
|
||||||
|
border border-amber-500/30
|
||||||
|
bg-[linear-gradient(90deg,var(--color-notice-warning-soft),var(--color-surface))]
|
||||||
|
text-text shadow-sm"
|
||||||
|
>
|
||||||
|
İncelemede
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRejected && (
|
||||||
|
<div className="absolute top-3 right-3 z-10">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold
|
||||||
|
border border-rose-500/30
|
||||||
|
bg-[linear-gradient(90deg,var(--color-notice-danger-soft),var(--color-surface))]
|
||||||
|
text-text shadow-sm"
|
||||||
|
>
|
||||||
|
Reddedildi
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fotoğraf kısmı - En solda */}
|
||||||
|
<div className="w-57 h-57 flex-shrink-1 rounded-l-l bg-background overflow-hidden">
|
||||||
|
<img src={image} alt={title} className="w-full h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* İçerik kısmı - padding sadece burada olacak */}
|
||||||
|
<div className="flex flex-col justify-between flex-1 min-w-0 p-4">
|
||||||
|
{/* İlk Satır - Voteler ve Ne Zaman Gönderildi */}
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
|
{/* Voteler */}
|
||||||
|
{showVotes && (
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="shrink-0 flex items-center gap-1 rounded-2xl bg-background border border-white p-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
disabled={voting}
|
||||||
|
onClick={handleUp}
|
||||||
|
className={[
|
||||||
|
"p-1 rounded-full transition", // padding'i küçülttük
|
||||||
|
currentVote === 1 ? "text-green-500" : "text-text-muted hover:text-text",
|
||||||
|
voting ? "opacity-60 cursor-not-allowed" : "cursor-pointer",
|
||||||
|
].join(" ")}
|
||||||
|
aria-label="Upvote"
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-4 h-4" /> {/* icon boyutunu küçülttük */}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="min-w-[34px] text-center text-s font-bold text-text" // font-size'ı text-xs olarak küçülttük
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
{currentScore ?? 0}°
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
disabled={voting}
|
||||||
|
onClick={handleDown}
|
||||||
|
className={[
|
||||||
|
"p-1 rounded-full transition", // padding'i küçülttük
|
||||||
|
currentVote === -1 ? "text-red-500" : "text-text-muted hover:text-text ",
|
||||||
|
voting ? "opacity-60 cursor-not-allowed" : "cursor-pointer",
|
||||||
|
].join(" ")}
|
||||||
|
aria-label="Downvote"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-4 h-4" /> {/* icon boyutunu küçülttük */}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gönderilme Zamanı */}
|
||||||
|
<div className="flex items-center text-s text-text-muted">
|
||||||
|
<span>{postedAgo}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Başlık ve Açıklama */}
|
||||||
<div className="flex flex-col justify-between flex-1 min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<h2 className="text-xl font-bold text-text mt-1 hover:text-primary line-clamp-2">
|
||||||
<div className="min-w-0">
|
{title}
|
||||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
</h2>
|
||||||
<span>{postedAgo}</span>
|
|
||||||
<span className="opacity-50">•</span>
|
|
||||||
<span className="truncate">{postedBy} paylaştı</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold text-text mt-1 hover:text-primary line-clamp-2">
|
<div className="flex items-baseline gap-2 ">
|
||||||
{title}
|
<span className="text-primary text-2xl font-semibold">{priceText}</span>
|
||||||
</h2>
|
<span className="text-sm text-text-muted">{store}</span>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
<span className="text-primary font-bold text-xl">{price}</span>
|
|
||||||
<span className="text-sm text-text-muted">{store}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Vote pill */}
|
|
||||||
<div
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="shrink-0 flex items-center gap-1 rounded-full bg-background border border-white/10 p-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
disabled={voting}
|
|
||||||
onClick={handleUp}
|
|
||||||
className={[
|
|
||||||
"p-2 rounded-full transition",
|
|
||||||
currentVote === 1 ? "text-green-500" : "text-text-muted hover:text-text",
|
|
||||||
voting ? "opacity-60 cursor-not-allowed" : "",
|
|
||||||
].join(" ")}
|
|
||||||
aria-label="Upvote"
|
|
||||||
>
|
|
||||||
<ChevronUp className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="min-w-[34px] text-center text-sm font-semibold text-text">
|
|
||||||
{currentScore ?? 0}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled={voting}
|
|
||||||
onClick={handleDown}
|
|
||||||
className={[
|
|
||||||
"p-2 rounded-full transition",
|
|
||||||
currentVote === -1 ? "text-red-500" : "text-text-muted hover:text-text",
|
|
||||||
voting ? "opacity-60 cursor-not-allowed" : "",
|
|
||||||
].join(" ")}
|
|
||||||
aria-label="Downvote"
|
|
||||||
>
|
|
||||||
<ChevronDown className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom actions */}
|
{/* Fiyatın altına description (açıklama) ekliyoruz */}
|
||||||
<div className="flex items-center justify-between mt-4">
|
{deal.description && (
|
||||||
<div className="flex items-center gap-4 text-sm text-text-muted">
|
<p className="text-sm text-text-muted mt-2 line-clamp-2">{deal.description}</p>
|
||||||
<span className="inline-flex items-center gap-2">
|
)}
|
||||||
<MessageCircle className="w-4 h-4" />
|
</div>
|
||||||
{comments}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<button
|
<div className="flex items-center justify-between mt-4">
|
||||||
type="button"
|
<div className="flex items-center gap-4 text-sm text-text-muted">
|
||||||
onClick={(e) => {
|
{/* Yorum kısmı üzerine gelince turuncu olmalı */}
|
||||||
e.stopPropagation()
|
<span className="inline-flex items-center gap-2 hover:text-orange-500 transition cursor-pointer">
|
||||||
// burada share handler yazarsın
|
<MessageCircle className="w-4 h-4" />
|
||||||
}}
|
{comments}
|
||||||
className="inline-flex items-center gap-2 hover:text-text transition"
|
</span>
|
||||||
>
|
|
||||||
<Share2 className="w-4 h-4" />
|
|
||||||
Paylaş
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* Paylaş butonunun yanında Avatar ve Kullanıcı İsmi */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation()
|
onClick={(e) => e.stopPropagation()}
|
||||||
// CTA handler
|
className="inline-flex items-center gap-2 hover:text-orange-500 transition cursor-pointer"
|
||||||
}}
|
|
||||||
className="bg-primary text-black px-4 py-2 rounded-xl font-semibold hover:bg-primary-hover transition"
|
|
||||||
>
|
>
|
||||||
Fırsatı kap
|
<Share2 className="w-4 h-4" />
|
||||||
|
Paylaş
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Paylaşanın Avatar ve İsmi */}
|
||||||
|
<div className="flex items-center gap-2 hover:text-orange-500 transition cursor-pointer">
|
||||||
|
{/* Avatar */}
|
||||||
|
{deal.user?.avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={deal.user.avatarUrl}
|
||||||
|
alt={deal.user.username}
|
||||||
|
className="w-6 h-6 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="text-xs text-text-muted hover:text-orange-500"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Yönlendirme sırasında yayılmayı engelle
|
||||||
|
navigate(`/user/${deal.user?.username}`); // Kullanıcı profil sayfasına git
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deal.user?.username} tarafından paylaşıldı.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleGoToDeal}
|
||||||
|
className="bg-primary text-white px-4 py-2 rounded-xl font-semibold hover:bg-primary-hover active:scale-[0.98] transition shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary/40 cursor-pointer"
|
||||||
|
>
|
||||||
|
Fırsata git
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
src/components/Shared/DealsPresetTabs.tsx
Normal file
86
src/components/Shared/DealsPresetTabs.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import type { DealPreset } from "../../api/deal/getDeal"
|
||||||
|
|
||||||
|
const TABS: { key: DealPreset; label: string }[] = [
|
||||||
|
{ key: "hot", label: "Sıcak" },
|
||||||
|
{ key: "trending", label: "Trend" },
|
||||||
|
{ key: "new", label: "Yeni" },
|
||||||
|
]
|
||||||
|
|
||||||
|
type DealsPresetTabsProps = {
|
||||||
|
currentPreset: DealPreset
|
||||||
|
onSelect: (preset: DealPreset) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DealsPresetTabs({ currentPreset, onSelect }: DealsPresetTabsProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||||
|
const [indicator, setIndicator] = useState({ left: 0, width: 0 })
|
||||||
|
|
||||||
|
const activeIndex = useMemo(
|
||||||
|
() => Math.max(0, TABS.findIndex((t) => t.key === currentPreset)),
|
||||||
|
[currentPreset]
|
||||||
|
)
|
||||||
|
|
||||||
|
const updateIndicator = () => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const activeKey = TABS[activeIndex]?.key
|
||||||
|
const btn = activeKey ? btnRefs.current[activeKey] : null
|
||||||
|
if (!container || !btn) return
|
||||||
|
|
||||||
|
const cRect = container.getBoundingClientRect()
|
||||||
|
const bRect = btn.getBoundingClientRect()
|
||||||
|
|
||||||
|
// container içindeki left offset
|
||||||
|
const left = bRect.left - cRect.left
|
||||||
|
const width = bRect.width
|
||||||
|
|
||||||
|
setIndicator({ left, width })
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
updateIndicator()
|
||||||
|
// resize olunca da otursun
|
||||||
|
const onResize = () => updateIndicator()
|
||||||
|
window.addEventListener("resize", onResize)
|
||||||
|
return () => window.removeEventListener("resize", onResize)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeIndex])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative inline-flex items-center rounded-full bg-surface-2 p-1 border border-white/10"
|
||||||
|
>
|
||||||
|
{/* sliding indicator (tam yazının oturduğu button genişliği kadar) */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1 bottom-1 rounded-full bg-background/50 border border-white/10 shadow-sm transition-all duration-200"
|
||||||
|
style={{ left: indicator.left, width: indicator.width }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{TABS.map((tab) => {
|
||||||
|
const isActive = tab.key === currentPreset
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
ref={(el) => {
|
||||||
|
btnRefs.current[tab.key] = el
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(tab.key)}
|
||||||
|
className={[
|
||||||
|
"relative z-10",
|
||||||
|
"px-4 py-2 text-m font-bold rounded-full",
|
||||||
|
"transition-colorstext-primary",
|
||||||
|
// turuncu ring iptal:
|
||||||
|
"focus:outline-none",
|
||||||
|
isActive ? "text-primary shadow " : "text-text-muted hover:text-text",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
155
src/components/Shared/FilterPanel.tsx
Normal file
155
src/components/Shared/FilterPanel.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
const FilterMenu = () => {
|
||||||
|
const [price, setPrice] = useState(1500);
|
||||||
|
const [temperature, setTemperature] = useState('any');
|
||||||
|
const [dateRange, setDateRange] = useState('last-year');
|
||||||
|
const [retailer, setRetailer] = useState('Amazon');
|
||||||
|
|
||||||
|
const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPrice(Number(e.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTemperatureChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setTemperature(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateRangeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setDateRange(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetailerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setRetailer(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface p-6 shadow-lg rounded-lg border border-border">
|
||||||
|
<h3 className="text-lg font-semibold text-text mb-4">Filteler</h3>
|
||||||
|
|
||||||
|
{/* Sort By */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-text-muted mb-2">Sıralama</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-3 border border-border rounded-md focus:ring-2 focus:ring-primary transition-all"
|
||||||
|
value={temperature}
|
||||||
|
onChange={handleTemperatureChange}
|
||||||
|
>
|
||||||
|
<option value="most-recent">En yeni</option>
|
||||||
|
<option value="lowest-price">En düşük fiyat</option>
|
||||||
|
<option value="highest-price">En yüksek fiyat</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price Slider */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-text-muted mb-2">Fiyat</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="3000"
|
||||||
|
step="10"
|
||||||
|
value={price}
|
||||||
|
onChange={handlePriceChange}
|
||||||
|
className="w-full h-2 bg-gradient-to-r from-orange-400 to-orange-600 rounded-lg appearance-none focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-sm text-text-muted mt-2">
|
||||||
|
<span>£0</span><span>£3000</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Temperature Radio Buttons */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-text-muted mb-2">Sıcaklık</label>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="temperature"
|
||||||
|
value="any"
|
||||||
|
checked={temperature === 'any'}
|
||||||
|
onChange={handleTemperatureChange}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
Herhangi bir sıcaklık
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="temperature"
|
||||||
|
value="20-up"
|
||||||
|
checked={temperature === '20-up'}
|
||||||
|
onChange={handleTemperatureChange}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
20° & üstü
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="temperature"
|
||||||
|
value="100-up"
|
||||||
|
checked={temperature === '100-up'}
|
||||||
|
onChange={handleTemperatureChange}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
100° & üstü
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="temperature"
|
||||||
|
value="500-up"
|
||||||
|
checked={temperature === '500-up'}
|
||||||
|
onChange={handleTemperatureChange}
|
||||||
|
className="mr-2"
|
||||||
|
/>
|
||||||
|
500° & üstü
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Range Dropdown */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-text-muted mb-2">Tarih Aralığı</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-3 border border-border rounded-md focus:ring-2 focus:ring-primary transition-all"
|
||||||
|
value={dateRange}
|
||||||
|
onChange={handleDateRangeChange}
|
||||||
|
>
|
||||||
|
<option value="last-year">Geçen yıl</option>
|
||||||
|
<option value="last-month">Geçen ay</option>
|
||||||
|
<option value="last-week">Geçen hafta</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Retailer Dropdown */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm text-text-muted mb-2">Perakendeci</label>
|
||||||
|
<select
|
||||||
|
className="w-full p-3 border border-border rounded-md focus:ring-2 focus:ring-primary transition-all"
|
||||||
|
value={retailer}
|
||||||
|
onChange={handleRetailerChange}
|
||||||
|
>
|
||||||
|
<option value="Amazon">Amazon</option>
|
||||||
|
<option value="eBay">eBay</option>
|
||||||
|
<option value="Best Buy">Best Buy</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Button */}
|
||||||
|
<button
|
||||||
|
className="w-full p-3 bg-primary text-on-primary rounded-lg hover:bg-primary-hover transition-all"
|
||||||
|
onClick={() => {
|
||||||
|
setPrice(1500);
|
||||||
|
setTemperature('any');
|
||||||
|
setDateRange('last-year');
|
||||||
|
setRetailer('Amazon');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Filtreleri Sıfırla
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FilterMenu;
|
||||||
157
src/components/Sidebar/HotDealsSidebar.tsx
Normal file
157
src/components/Sidebar/HotDealsSidebar.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { fetchTopDeals, type TopRange } from "../../api/deal/getTopDeals";
|
||||||
|
import { scoreToHeat } from "../../utils/heat";
|
||||||
|
|
||||||
|
const LIMIT = 6;
|
||||||
|
|
||||||
|
const TABS: { key: TopRange; label: string }[] = [
|
||||||
|
{ key: "day", label: "Günlük" },
|
||||||
|
{ key: "week", label: "Haftalık" },
|
||||||
|
{ key: "month", label: "Aylık" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HotDealsSidebar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [range, setRange] = useState<TopRange>("day");
|
||||||
|
const [items, setItems] = useState<any[]>([]); // Initialize with the correct type
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchTopDeals(range, LIMIT);
|
||||||
|
if (canceled) return;
|
||||||
|
setItems(Array.isArray(data) ? data.slice(0, LIMIT) : []);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (canceled) return;
|
||||||
|
setErr(e?.message ?? "Bir hata oluştu");
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
if (!canceled) setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded shadow-lg bg-surface p-4">
|
||||||
|
{/* Title and Tabs */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-semibold text-grey">En İyi İlanlar</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex justify-center">
|
||||||
|
<div className="relative inline-flex items-center rounded-full bg-background shadow-md p-1">
|
||||||
|
{/* Sliding indicator */}
|
||||||
|
<div
|
||||||
|
className="absolute top-1 bottom-1 rounded-full transition-all bg-background/50 border border-white/10 shadow-sm duration-200"
|
||||||
|
style={{}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{TABS.map((t) => {
|
||||||
|
const isActive = t.key === range;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRange(t.key)}
|
||||||
|
className={[
|
||||||
|
"relative z-10 px-3 py-1.5 rounded-full text-xs font-semibold transition-colors cursor-pointer",
|
||||||
|
isActive ? "text-primary" : "text-text-muted hover:text-text",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<div className="mt-3 rounded-xl shadow-md bg-[linear-gradient(90deg,var(--color-notice-danger-soft),var(--color-surface))] px-3 py-2 text-xs text-text">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Items - Vertical Layout (1 item per row) */}
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
{loading && items.length === 0
|
||||||
|
? Array.from({ length: LIMIT }).map((_, i) => (
|
||||||
|
<div key={i} className="h-[200px] rounded-2xl shadow-md bg-background/40 animate-pulse" />
|
||||||
|
))
|
||||||
|
: items.map((d) => {
|
||||||
|
const img = d.imageUrl || "/placeholder.png";
|
||||||
|
const price = d.price ? `${d.price}₺` : "";
|
||||||
|
const title = d.title || "Ürün adı";
|
||||||
|
const { degree, color } = scoreToHeat(d.score ?? 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/deal/${d.id}`)}
|
||||||
|
className="group text-left w-full hover:bg-background/20 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Left: Image */}
|
||||||
|
<div className="w-1/3">
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={title}
|
||||||
|
className="w-full h-full object-cover rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Details */}
|
||||||
|
<div className="w-2/3">
|
||||||
|
<div className="text-xs font-semibold text-text line-clamp-2 group-hover:text-primary transition">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-sm font-bold text-primary">{price}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Heat Indicator */}
|
||||||
|
<div
|
||||||
|
className="absolute top-2 right-2 px-2 py-1 text-[12px] font-bold text-white rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
{degree}°
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* No items or error */}
|
||||||
|
{!loading && items.length === 0 && !err && (
|
||||||
|
<div className="rounded-2xl shadow-md bg-background/40 p-4 text-center">
|
||||||
|
<div className="text-sm font-semibold text-text">Gösterilecek fırsat yok</div>
|
||||||
|
<div className="text-xs text-text-muted mt-1">Daha sonra tekrar dene.</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View All Button */}
|
||||||
|
<div className="text-center mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/deals/${range}`)}
|
||||||
|
className="btn btn-primary w-full py-2 rounded-md text-white"
|
||||||
|
>
|
||||||
|
Hepsini Gör
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,42 +1,38 @@
|
||||||
import { createContext, useContext, useState, useEffect } from "react"
|
import { createContext, useContext, useState } from "react"
|
||||||
|
import instance from "../api/axiosInstance"
|
||||||
|
|
||||||
type AuthContextType = {
|
type AuthContextType = {
|
||||||
user: any
|
user: any
|
||||||
token: string | null
|
token: string | null
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
login: (userData: any, tokenData: string) => void
|
login: (userData: any, tokenData: string) => void
|
||||||
logout: () => void
|
logout: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null)
|
const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [user, setUser] = useState<any>(null)
|
const [token, setToken] = useState<string | null>(() => localStorage.getItem("token"))
|
||||||
const [token, setToken] = useState<string | null>(null)
|
const [user, setUser] = useState<any>(() => {
|
||||||
|
const raw = localStorage.getItem("user")
|
||||||
useEffect(() => {
|
return raw ? JSON.parse(raw) : null
|
||||||
const storedUser = localStorage.getItem("user")
|
})
|
||||||
const storedToken = localStorage.getItem("token")
|
|
||||||
if (storedUser && storedToken) {
|
|
||||||
setUser(JSON.parse(storedUser))
|
|
||||||
setToken(storedToken)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const login = (userData: any, tokenData: string) => {
|
const login = (userData: any, tokenData: string) => {
|
||||||
localStorage.setItem("user", JSON.stringify(userData))
|
localStorage.setItem("user", JSON.stringify(userData))
|
||||||
localStorage.setItem("token", tokenData)
|
localStorage.setItem("token", tokenData)
|
||||||
setUser(userData)
|
setUser(userData)
|
||||||
setToken(tokenData)
|
setToken(tokenData)
|
||||||
window.location.reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await instance.post("/auth/logout")
|
||||||
|
} catch {}
|
||||||
localStorage.removeItem("user")
|
localStorage.removeItem("user")
|
||||||
localStorage.removeItem("token")
|
localStorage.removeItem("token")
|
||||||
setUser(null)
|
setUser(null)
|
||||||
setToken(null)
|
setToken(null)
|
||||||
window.location.reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
58
src/data/categories.ts
Normal file
58
src/data/categories.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
export type CategoryNode = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
children?: CategoryNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DUMMY_CATEGORIES: CategoryNode[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Elektronik",
|
||||||
|
slug: "electronics",
|
||||||
|
children: [
|
||||||
|
{ id: 11, name: "Telefon", slug: "phone" },
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
name: "Bilgisayar",
|
||||||
|
slug: "bilgisayar",
|
||||||
|
children: [
|
||||||
|
{ id: 121, name: "Laptop", slug: "laptop" },
|
||||||
|
{ id: 122, name: "Masaüstü", slug: "masaustu" },
|
||||||
|
{ id: 123, name: "PC Çevre Birimleri", slug: "pc-peripherals" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: 13, name: "TV", slug: "tv" },
|
||||||
|
{ id: 14, name: "Monitör", slug: "monitor" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: "Ev & Yaşam",
|
||||||
|
slug: "ev-yasam",
|
||||||
|
children: [
|
||||||
|
{ id: 21, name: "Mutfak", slug: "mutfak" },
|
||||||
|
{ id: 22, name: "Temizlik", slug: "temizlik" },
|
||||||
|
{ id: 23, name: "Mobilya", slug: "mobilya" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: "Moda",
|
||||||
|
slug: "moda",
|
||||||
|
children: [
|
||||||
|
{ id: 31, name: "Erkek", slug: "erkek" },
|
||||||
|
{ id: 32, name: "Kadın", slug: "kadin" },
|
||||||
|
{ id: 33, name: "Ayakkabı", slug: "ayakkabi" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
name: "Oyun",
|
||||||
|
slug: "oyun",
|
||||||
|
children: [
|
||||||
|
{ id: 41, name: "Konsol", slug: "konsol" },
|
||||||
|
{ id: 42, name: "Oyun Aksesuarları", slug: "oyun-aksesuarlari" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
95
src/global copy.css
Normal file
95
src/global copy.css
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* -------------------------------------------------
|
||||||
|
THEME TOKENS (Tailwind v4 @theme değişkenleri)
|
||||||
|
Default: LIGHT
|
||||||
|
Dark: .dark class'ı ile override
|
||||||
|
-------------------------------------------------- */
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* LIGHT (soft graphite) */
|
||||||
|
--color-background: #D6DAE1;
|
||||||
|
--color-surface: #E1E5EB;
|
||||||
|
--color-surface-2: #CBD1DA;
|
||||||
|
--color-border: #B3BBC7;
|
||||||
|
|
||||||
|
--color-text: #1C212B;
|
||||||
|
--color-text-muted: #5D6675;
|
||||||
|
|
||||||
|
--color-primary: #FF6A00;
|
||||||
|
--color-primary-hover: #E85F00;
|
||||||
|
--color-primary-soft: rgba(255, 106, 0, 0.14);
|
||||||
|
--color-primary-ring: rgba(255, 106, 0, 0.30);
|
||||||
|
|
||||||
|
--color-success: #16A34A;
|
||||||
|
--color-danger: #EF4444;
|
||||||
|
|
||||||
|
--color-on-primary: #111318;
|
||||||
|
|
||||||
|
/* ✅ DEAL NOTICE (LIGHT) */
|
||||||
|
--color-notice-info: #0EA5E9;
|
||||||
|
--color-notice-success: #10B981;
|
||||||
|
--color-notice-warning: #F59E0B;
|
||||||
|
--color-notice-danger: #EF4444;
|
||||||
|
|
||||||
|
--color-notice-info-soft: rgba(14, 165, 233, 0.14);
|
||||||
|
--color-notice-success-soft: rgba(16, 185, 129, 0.14);
|
||||||
|
--color-notice-warning-soft: rgba(245, 158, 11, 0.14);
|
||||||
|
--color-notice-danger-soft: rgba(239, 68, 68, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark overrides (class tabanlı) */
|
||||||
|
.dark {
|
||||||
|
/* DARK */
|
||||||
|
--color-background: #0F0F10;
|
||||||
|
--color-surface: #17181A;
|
||||||
|
--color-surface-2: #1F2124;
|
||||||
|
--color-border: #2A2D31;
|
||||||
|
|
||||||
|
--color-text: #F2F3F5;
|
||||||
|
--color-text-muted: #A7ABB3;
|
||||||
|
|
||||||
|
--color-primary: #FF6A00;
|
||||||
|
--color-primary-hover: #E85F00;
|
||||||
|
--color-primary-soft: rgba(255, 106, 0, 0.16);
|
||||||
|
--color-primary-ring: rgba(255, 106, 0, 0.35);
|
||||||
|
|
||||||
|
--color-success: #2ECC71;
|
||||||
|
--color-danger: #FF4D4D;
|
||||||
|
|
||||||
|
--color-on-primary: #111214;
|
||||||
|
|
||||||
|
/* ✅ DEAL NOTICE (DARK) */
|
||||||
|
--color-notice-info: #38BDF8;
|
||||||
|
--color-notice-success: #34D399;
|
||||||
|
--color-notice-warning: #FBBF24;
|
||||||
|
--color-notice-danger: #FB7185;
|
||||||
|
|
||||||
|
--color-notice-info-soft: rgba(56, 189, 248, 0.18);
|
||||||
|
--color-notice-success-soft: rgba(52, 211, 153, 0.18);
|
||||||
|
--color-notice-warning-soft: rgba(251, 191, 36, 0.18);
|
||||||
|
--color-notice-danger-soft: rgba(251, 113, 133, 0.18);
|
||||||
|
}
|
||||||
|
/* Base */
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Rubik", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
@apply bg-background text-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&display=swap');
|
@import url("https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap");
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
@ -9,29 +9,56 @@
|
||||||
-------------------------------------------------- */
|
-------------------------------------------------- */
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
/* LIGHT (soft graphite) */
|
/* =========================
|
||||||
--color-background: #D6DAE1; /* sayfa: açık gri ama beyaz değil */
|
BACKGROUND / SURFACES
|
||||||
--color-surface: #E1E5EB; /* kart */
|
========================= */
|
||||||
--color-surface-2: #CBD1DA; /* input/secondary */
|
|
||||||
--color-border: #B3BBC7; /* border */
|
|
||||||
|
|
||||||
--color-text: #1C212B; /* koyu gri */
|
--color-background: #F4F6F9; /* sayfa arkası */
|
||||||
--color-text-muted: #5D6675;
|
--color-surface: #FFFFFF; /* ana deal card */
|
||||||
|
--color-surface-2: #EEF1F5; /* sidebar, secondary panels */
|
||||||
|
--color-border: #E3E7ED;
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
TEXT
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
--color-text: #0F172A; /* başlık + fiyat */
|
||||||
|
--color-text-muted: #64748B; /* meta, açıklama */
|
||||||
|
--color-text-faint: #94A3B8; /* timestamps, icons */
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
PRIMARY (CTA ONLY)
|
||||||
|
========================= */
|
||||||
|
|
||||||
--color-primary: #FF6A00;
|
--color-primary: #FF6A00;
|
||||||
--color-primary-hover: #E85F00;
|
--color-primary-hover: #E85F00;
|
||||||
--color-primary-soft: rgba(255, 106, 0, 0.14);
|
--color-primary-soft: rgba(255, 106, 0, 0.12);
|
||||||
--color-primary-ring: rgba(255, 106, 0, 0.30);
|
--color-primary-ring: rgba(255, 106, 0, 0.28);
|
||||||
|
--color-grey:#334155;
|
||||||
|
--color-on-primary: #FFFFFF;
|
||||||
|
|
||||||
--color-success: #16A34A;
|
/* =========================
|
||||||
--color-danger: #EF4444;
|
DEAL SCORE
|
||||||
|
========================= */
|
||||||
|
|
||||||
--color-on-primary: #111318;
|
--color-success: #16A34A;
|
||||||
|
--color-success-soft: rgba(22, 163, 74, 0.14);
|
||||||
|
|
||||||
|
--color-danger: #DC2626;
|
||||||
|
--color-danger-soft: rgba(220, 38, 38, 0.14);
|
||||||
|
|
||||||
|
/* =========================
|
||||||
|
NOTICE / INFO
|
||||||
|
========================= */
|
||||||
|
|
||||||
|
--color-info: #0284C7;
|
||||||
|
--color-info-soft: rgba(2, 132, 199, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Dark overrides (class tabanlı) */
|
/* Dark overrides (class tabanlı) */
|
||||||
.dark {
|
.dark {
|
||||||
/* DARK (seninki iyiydi; hafif rafine) */
|
/* DARK */
|
||||||
--color-background: #0F0F10;
|
--color-background: #0F0F10;
|
||||||
--color-surface: #17181A;
|
--color-surface: #17181A;
|
||||||
--color-surface-2: #1F2124;
|
--color-surface-2: #1F2124;
|
||||||
|
|
@ -49,7 +76,19 @@
|
||||||
--color-danger: #FF4D4D;
|
--color-danger: #FF4D4D;
|
||||||
|
|
||||||
--color-on-primary: #111214;
|
--color-on-primary: #111214;
|
||||||
|
|
||||||
|
/* ✅ DEAL NOTICE (DARK) */
|
||||||
|
--color-notice-info: #38BDF8;
|
||||||
|
--color-notice-success: #34D399;
|
||||||
|
--color-notice-warning: #FBBF24;
|
||||||
|
--color-notice-danger: #FB7185;
|
||||||
|
|
||||||
|
--color-notice-info-soft: rgba(56, 189, 248, 0.18);
|
||||||
|
--color-notice-success-soft: rgba(52, 211, 153, 0.18);
|
||||||
|
--color-notice-warning-soft: rgba(251, 191, 36, 0.18);
|
||||||
|
--color-notice-danger-soft: rgba(251, 113, 133, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base */
|
/* Base */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
@ -60,7 +99,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: "Rubik", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
font-family: "Nunito", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
@apply bg-background text-text;
|
@apply bg-background text-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { useState, useEffect } from "react"
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const [user, setUser] = useState<any>(null)
|
|
||||||
const [token, setToken] = useState<string | null>(null)
|
|
||||||
|
|
||||||
// Uygulama açıldığında localStorage kontrolü
|
|
||||||
useEffect(() => {
|
|
||||||
const storedUser = localStorage.getItem("user")
|
|
||||||
const storedToken = localStorage.getItem("token")
|
|
||||||
if (storedUser && storedToken) {
|
|
||||||
setUser(JSON.parse(storedUser))
|
|
||||||
setToken(storedToken)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Giriş başarılı olduğunda çağrılır
|
|
||||||
const login = (userData: any, tokenData: string) => {
|
|
||||||
localStorage.setItem("user", JSON.stringify(userData))
|
|
||||||
localStorage.setItem("token", tokenData)
|
|
||||||
setUser(userData)
|
|
||||||
setToken(tokenData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Çıkış yapıldığında çağrılır
|
|
||||||
const logout = () => {
|
|
||||||
localStorage.removeItem("user")
|
|
||||||
localStorage.removeItem("token")
|
|
||||||
setUser(null)
|
|
||||||
setToken(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAuthenticated = Boolean(token)
|
|
||||||
|
|
||||||
return { user, token, isAuthenticated, login, logout }
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
// src/hooks/useAuthCheck.ts
|
|
||||||
import { useEffect } from "react"
|
|
||||||
import { useNavigate, useLocation } from "react-router-dom"
|
|
||||||
import { useAuth } from "../context/AuthContext"
|
|
||||||
import instance from "../api/axiosInstance"
|
|
||||||
|
|
||||||
export function useAuthCheck() {
|
|
||||||
const { token, logout } = useAuth()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!token) {
|
|
||||||
navigate(`/login?next=${location.pathname}`, { replace: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
instance
|
|
||||||
.get("/auth/me")
|
|
||||||
.catch(() => {
|
|
||||||
logout()
|
|
||||||
navigate(`/login?next=${location.pathname}`, { replace: true })
|
|
||||||
})
|
|
||||||
}, [token, location.pathname, logout, navigate])
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,61 @@
|
||||||
|
import React, { useEffect, useMemo, useState } from "react"
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom"
|
||||||
|
|
||||||
import Navbar from "../components/Layout/Navbar/Navbar"
|
import Navbar from "../components/Layout/Navbar/Navbar"
|
||||||
import Footer from "../components/Layout/Footer"
|
import Footer from "../components/Layout/Footer"
|
||||||
|
import CategoriesSidebar from "../components/Categories/CategoriesSidebar" // path’i düzelt
|
||||||
|
import { DUMMY_CATEGORIES } from "../data/categories" // path’i düzelt
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MainLayout({ children }: Props) {
|
export default function MainLayout({ children }: Props) {
|
||||||
|
const [catsOpen, setCatsOpen] = useState(false)
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const activeSlug = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
return params.get("cat")
|
||||||
|
}, [location.search])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCatsOpen(false)
|
||||||
|
}, [location.pathname, location.search])
|
||||||
|
|
||||||
|
const setCategory = (slug: string) => {
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
params.set("cat", slug)
|
||||||
|
navigate({ pathname: location.pathname, search: params.toString() }, { replace: false })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-background text-text">
|
<div className="min-h-screen flex flex-col bg-background text-text">
|
||||||
{/* Navbar fixed + full width */}
|
{/* Navbar fixed + full width */}
|
||||||
<Navbar />
|
<Navbar
|
||||||
|
onToggleCategories={() => setCatsOpen((v) => !v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CategoriesSidebar
|
||||||
|
isOpen={catsOpen}
|
||||||
|
categories={DUMMY_CATEGORIES}
|
||||||
|
activeSlug={activeSlug}
|
||||||
|
onClose={() => setCatsOpen(false)}
|
||||||
|
onSelect={setCategory}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Content: navbar yüksekliği kadar boşluk */}
|
{/* Content: navbar yüksekliği kadar boşluk */}
|
||||||
<main className="flex-1 bg-background pt-14">
|
<main className="flex-1 bg-background pt-21">
|
||||||
<div className="max-w-[1400px] mx-auto px-4">
|
<div className="max-w-[1400px] mx-auto ">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
|
|
||||||
import "./global.css"
|
import "./global.css"
|
||||||
import { AuthProvider } from "./context/AuthContext"
|
import { AuthProvider } from "./context/AuthContext"
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
|
|
||||||
8
src/models/Shared/Breadcrumb.ts
Normal file
8
src/models/Shared/Breadcrumb.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// src/models/deal/Breadcrumb.ts
|
||||||
|
export type BreadcrumbItem = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Breadcrumb = BreadcrumbItem[]
|
||||||
11
src/models/Shared/SimilarDeal.ts
Normal file
11
src/models/Shared/SimilarDeal.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// src/models/deal/SimilarDeal.ts
|
||||||
|
export type SimilarDeal = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
price: number | null
|
||||||
|
score: number
|
||||||
|
imageUrl: string
|
||||||
|
sellerName: string
|
||||||
|
createdAt: string | null
|
||||||
|
// url: string | null // backend eklersek açarsın
|
||||||
|
}
|
||||||
12
src/models/category/CategoryDetailsModel.ts
Normal file
12
src/models/category/CategoryDetailsModel.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// models/CategoryDetailsModel.ts
|
||||||
|
import type { Breadcrumb } from "../Shared/Breadcrumb";
|
||||||
|
// Kategori detayları için model
|
||||||
|
export interface CategoryDetailsModel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string;
|
||||||
|
breadcrumb: Breadcrumb[]; // Breadcrumb'ın bir dizi olduğu varsayılıyor
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -5,5 +5,5 @@ export type Comment = {
|
||||||
text: string
|
text: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
user:PublicUserSummary
|
user:PublicUserSummary
|
||||||
deal:DealCard
|
deal?: DealCard | null
|
||||||
}
|
}
|
||||||
|
|
@ -3,13 +3,19 @@ import type { PublicUserSummary } from "../user/User"
|
||||||
import type { SellerSummary } from "..//seller/Seller"
|
import type { SellerSummary } from "..//seller/Seller"
|
||||||
import type { DealImage } from "./DealImage"
|
import type { DealImage } from "./DealImage"
|
||||||
import type { Comment } from "../comment/Comment"
|
import type { Comment } from "../comment/Comment"
|
||||||
|
import type { DealNotice } from "./DealNotice"
|
||||||
|
import type { Breadcrumb } from "../Shared/Breadcrumb"
|
||||||
|
import type { DealCard } from "./DealCard"
|
||||||
|
import type { SimilarDeal } from "../Shared/SimilarDeal"
|
||||||
|
export type DealNoticeSeverity = "INFO" | "WARNING" | "DANGER" | "SUCCESS"
|
||||||
|
|
||||||
export type DealDetail = {
|
export type DealDetail = {
|
||||||
id: number
|
id: number
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
|
||||||
url?: string
|
url?: string | null
|
||||||
price?: number
|
price?: number | null
|
||||||
score: number
|
score: number
|
||||||
commentsCount: number
|
commentsCount: number
|
||||||
|
|
||||||
|
|
@ -19,9 +25,12 @@ export type DealDetail = {
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
|
||||||
// ilişkiler
|
breadcrumb: Breadcrumb
|
||||||
user: PublicUserSummary
|
user: PublicUserSummary
|
||||||
seller: SellerSummary
|
seller: SellerSummary
|
||||||
images: DealImage[]
|
images: DealImage[]
|
||||||
comments: Comment[]
|
comments: Comment[]
|
||||||
|
similarDeals: SimilarDeal[]
|
||||||
|
// ✅ varsa gelir, yoksa undefined/null
|
||||||
|
notice?: DealNotice | null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
src/models/deal/DealNotice.ts
Normal file
12
src/models/deal/DealNotice.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
export type DealNoticeSeverity = "INFO" | "WARNING" | "DANGER" | "SUCCESS"
|
||||||
|
|
||||||
|
export type DealNotice = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
body?: string | null
|
||||||
|
severity: DealNoticeSeverity
|
||||||
|
isActive: boolean
|
||||||
|
createdBy: number
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
@ -2,4 +2,5 @@ export type UserStats = {
|
||||||
totalLikes: number
|
totalLikes: number
|
||||||
totalShares: number
|
totalShares: number
|
||||||
totalComments: number
|
totalComments: number
|
||||||
|
totalDeals: number
|
||||||
}
|
}
|
||||||
176
src/pages/CategoryPage.tsx
Normal file
176
src/pages/CategoryPage.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import MainLayout from "../layouts/MainLayout";
|
||||||
|
import DealCardMain from "../components/Shared/DealCardMain";
|
||||||
|
import { fetchCategoryDeals } from "../api/deal/getDeal"; // Import fetchCategoryDeals
|
||||||
|
import Breadcrumb from "../components/Shared/Breadcrumbs"; // Breadcrumb component
|
||||||
|
import FilterMenu from "../components/FilterMenu"; // FilterMenu component
|
||||||
|
import { fetchCategoryDetails } from "../api/category/getCategoryDetails"; // Fetch category details API
|
||||||
|
import type { DealCard } from "../models/deal/DealCard";
|
||||||
|
import type { CategoryDetailsModel } from "../models/category/CategoryDetailsModel";
|
||||||
|
import FilterPanel from "../components/Shared/FilterPanel";
|
||||||
|
type CategoryPageProps = {};
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
|
||||||
|
export default function CategoryPage({}: CategoryPageProps) {
|
||||||
|
const { categorySlug } = useParams<{ categorySlug: string }>(); // Get slug from URL
|
||||||
|
const [deals, setDeals] = useState<DealCard[]>([]); // Initialize with the correct type
|
||||||
|
const [categoryDetails, setCategoryDetails] = useState<CategoryDetailsModel | null>(null); // Category details state
|
||||||
|
const [breadcrumb, setBreadcrumb] = useState<any[]>([]); // Breadcrumb state
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Fetch category details and deals based on categorySlug
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Fetch category details (including breadcrumb)
|
||||||
|
fetchCategoryDetails(categorySlug)
|
||||||
|
.then((response) => {
|
||||||
|
setCategoryDetails(response); // Set category details
|
||||||
|
setBreadcrumb(response.breadcrumb); // Set breadcrumb
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching category details:", error);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false)); // Stop loading once done
|
||||||
|
}, [categorySlug]); // Only fetch category details when categorySlug changes
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Avoid fetching deals if already loading
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
setLoading(true); // Set loading true to prevent another fetch while loading
|
||||||
|
|
||||||
|
// Fetch category deals based on page and categorySlug
|
||||||
|
fetchCategoryDeals(categorySlug, page, LIMIT)
|
||||||
|
.then((response) => {
|
||||||
|
// Append new deals to existing ones
|
||||||
|
setDeals((prevDeals) => {
|
||||||
|
if (page === 1) {
|
||||||
|
// If it's the first page, replace existing deals with new ones
|
||||||
|
return response;
|
||||||
|
} else {
|
||||||
|
// Otherwise, append new deals
|
||||||
|
return [...prevDeals, ...response];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setHasMore(response.length === LIMIT); // If response length is equal to LIMIT, more data is available
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error fetching category deals:", error);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false)); // Stop loading once done
|
||||||
|
}, [categorySlug, page]); // Fetch deals when page or categorySlug changes
|
||||||
|
|
||||||
|
const loadMoreDeals = () => {
|
||||||
|
if (hasMore && !loading) {
|
||||||
|
setPage((prevPage) => prevPage + 1); // Increment page number for next fetch
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout>
|
||||||
|
<div className="max-w-[1400px] mx-auto px-4 py-8 ">
|
||||||
|
{/* 1) Breadcrumb Section */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<Breadcrumb breadcrumb={breadcrumb} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div className="w-full h-[400px] mb-8 bg-white p-12 flex items-center overflow-hidden">
|
||||||
|
<div className="relative max-w-screen-xl mx-auto text-left text-black w-full flex items-center">
|
||||||
|
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-5xl font-bold mb-2 text-grey">{categoryDetails?.name || "Kategori Adı"}</h1>
|
||||||
|
<p className="text-lg max-w-4xl mb-8 text-grey">
|
||||||
|
{categoryDetails?.description || "Kategori açıklaması burada yer alacak."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, colIndex) => {
|
||||||
|
// Pozisyon kaydırma için her sütuna farklı bir değer veriyoruz
|
||||||
|
const translateYValue = colIndex === 0 ? "-5%" : colIndex === 1 ? "5%" : "-5%";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={colIndex} className="grid grid-rows-3 gap-10 pl-20" style={{ transform: `translateY(${translateYValue})` }}>
|
||||||
|
{deals.slice(colIndex * 3, (colIndex + 1) * 3).map((deal, index) => (
|
||||||
|
<div key={index} className="w-full h-42 bg-white overflow-hidden rounded-lg shadow-2xl opacity-80 pointer-events-none user-select-none">
|
||||||
|
<img
|
||||||
|
src={deal.imageUrl || "/placeholder.png"}
|
||||||
|
alt={deal.title}
|
||||||
|
className="w-full h-full object-center rounded-lg "
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* 3) Category Content: Filters on the left and Deals on the right */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Left: Filter Menu */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<FilterPanel/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Deals */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`space-y-4 min-h-[60vh] transition-opacity duration-150 ${loading && deals.length > 0 ? "opacity-80" : ""}`}
|
||||||
|
>
|
||||||
|
{!loading && deals.length === 0 ? (
|
||||||
|
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
||||||
|
<div className="text-sm font-semibold text-text">Henüz fırsat yok</div>
|
||||||
|
<div className="text-xs text-text-muted mt-1">
|
||||||
|
Seçili kategoride henüz paylaşım bulunmuyor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
deals.map((deal) => (
|
||||||
|
<DealCardMain key={deal.id} deal={deal} onRequireLogin={() => {}} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && deals.length > 0 && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/70 text-text">
|
||||||
|
<p className="text-sm font-semibold">Yükleniyor...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && !loading && deals.length > 0 && (
|
||||||
|
<p className="text-center py-4 text-muted-foreground">Tüm fırsatlar yüklendi.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Load more button */}
|
||||||
|
{hasMore && !loading && (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<button onClick={loadMoreDeals} className="btn btn-primary">
|
||||||
|
Daha Fazla Yükle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
import MainLayout from "../layouts/MainLayout"
|
import MainLayout from "../layouts/MainLayout"
|
||||||
|
|
||||||
import { createDeal } from "../api/deal/newDeal"
|
import { createDeal } from "../api/deal/newDeal"
|
||||||
import { lookupSellerFromLink } from "../api/seller/from-lookup"
|
import { lookupSellerFromLink } from "../api/seller/from-lookup"
|
||||||
|
|
||||||
|
|
||||||
import { mapSellerFromLookupResponse } from "../adapters/responses/sellerFromLookupAdapter"
|
|
||||||
import { mapDealDraftToCreateRequest } from "../adapters/requests/dealCreateAdapter.ts"
|
import { mapDealDraftToCreateRequest } from "../adapters/requests/dealCreateAdapter.ts"
|
||||||
|
|
||||||
import DealLinkStep from "../components/CreateDeal/DealLinkStep"
|
import DealLinkStep from "../components/CreateDeal/DealLinkStep"
|
||||||
|
|
@ -13,11 +11,12 @@ import DealDetailsStep from "../components/CreateDeal/DealDetailsStep"
|
||||||
|
|
||||||
import type { SellerLookupInput } from "../api/seller/types.ts"
|
import type { SellerLookupInput } from "../api/seller/types.ts"
|
||||||
import type { DealDraft } from "../models/DealDraft"
|
import type { DealDraft } from "../models/DealDraft"
|
||||||
import type { Seller } from "../models/seller/Seller"
|
|
||||||
|
|
||||||
type Step = "link" | "details"
|
type Step = "link" | "details"
|
||||||
|
|
||||||
export default function CreateDealPage() {
|
export default function CreateDealPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const [step, setStep] = useState<Step>("link")
|
const [step, setStep] = useState<Step>("link")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
|
@ -27,20 +26,18 @@ export default function CreateDealPage() {
|
||||||
url: "",
|
url: "",
|
||||||
price: undefined,
|
price: undefined,
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
images: [], // <-- ekle
|
images: [], // <-- ekle
|
||||||
seller: {
|
seller: {
|
||||||
id: -1,
|
id: -1,
|
||||||
name: "",
|
name: "",
|
||||||
url: null,
|
url: null,
|
||||||
},
|
},
|
||||||
customCompany: undefined,
|
customCompany: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
/* -------- STEP 1 — LINK -------- */
|
/* -------- STEP 1 — LINK -------- */
|
||||||
|
|
||||||
const handleLinkSubmit = async (
|
const handleLinkSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e: React.FormEvent<HTMLFormElement>
|
|
||||||
) => {
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
// OFFLINE DEAL
|
// OFFLINE DEAL
|
||||||
|
|
@ -52,16 +49,18 @@ export default function CreateDealPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🔥 1. URL → Seller (temporary)
|
|
||||||
|
|
||||||
|
|
||||||
// 🔥 2. Seller → Lookup Request (ADAPTER)
|
// 🔥 2. Seller → Lookup Request (ADAPTER)
|
||||||
const input: SellerLookupInput = { url: dealDraft.url }
|
const input: SellerLookupInput = { url: dealDraft.url }
|
||||||
// 🔥 3. API CALL
|
// 🔥 3. API CALL
|
||||||
const seller = await lookupSellerFromLink(input)
|
const seller = await lookupSellerFromLink(input)
|
||||||
|
const nextSeller = seller ?? {
|
||||||
|
id: -1,
|
||||||
|
name: "",
|
||||||
|
url: null,
|
||||||
|
}
|
||||||
|
|
||||||
setDealDraft(d => {
|
setDealDraft((d) => {
|
||||||
const next = { ...d, seller }
|
const next = { ...d, seller: nextSeller }
|
||||||
console.log("NEXT:", next)
|
console.log("NEXT:", next)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
@ -69,7 +68,7 @@ export default function CreateDealPage() {
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Seller lookup failed:", err)
|
console.error("Seller lookup failed:", err)
|
||||||
|
|
||||||
setDealDraft(d => ({
|
setDealDraft((d) => ({
|
||||||
...d,
|
...d,
|
||||||
seller: {
|
seller: {
|
||||||
id: -1,
|
id: -1,
|
||||||
|
|
@ -88,13 +87,13 @@ export default function CreateDealPage() {
|
||||||
|
|
||||||
const handleFinalSubmit = async () => {
|
const handleFinalSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
|
const created = await createDeal(mapDealDraftToCreateRequest(dealDraft))
|
||||||
|
const id = created?.id
|
||||||
|
|
||||||
await createDeal(
|
if (!id) throw new Error("Deal oluşturuldu ama id dönmedi")
|
||||||
mapDealDraftToCreateRequest(dealDraft)
|
|
||||||
)
|
|
||||||
|
|
||||||
alert("Fırsat başarıyla gönderildi.")
|
// ✅ başarıyla oluşturulduysa deal sayfasına git
|
||||||
resetForm()
|
navigate(`/deal/${id}`)
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
alert(err instanceof Error ? err.message : "Sunucu hatası")
|
alert(err instanceof Error ? err.message : "Sunucu hatası")
|
||||||
}
|
}
|
||||||
|
|
@ -109,10 +108,10 @@ export default function CreateDealPage() {
|
||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
images: [],
|
images: [],
|
||||||
seller: {
|
seller: {
|
||||||
id: -1,
|
id: -1,
|
||||||
name: "",
|
name: "",
|
||||||
url: null,
|
url: null,
|
||||||
},
|
},
|
||||||
customCompany: undefined,
|
customCompany: undefined,
|
||||||
})
|
})
|
||||||
setStep("link")
|
setStep("link")
|
||||||
|
|
@ -120,14 +119,13 @@ export default function CreateDealPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div className="max-w-6xl mx-auto bg-surface p-6 mt-8 rounded-2xl">
|
<div className="max-w-6xl mx-auto bg-surface p-6 mt-8 rounded-2xl">
|
||||||
|
|
||||||
{step === "link" && (
|
{step === "link" && (
|
||||||
<DealLinkStep
|
<DealLinkStep
|
||||||
url={dealDraft.url}
|
url={dealDraft.url}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onChange={(url) =>
|
onChange={(url) =>
|
||||||
setDealDraft(d => ({
|
setDealDraft((d) => ({
|
||||||
...d,
|
...d,
|
||||||
url,
|
url,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
// src/pages/DealPage.tsx
|
// src/pages/DealPage.tsx
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
|
import Breadcrumbs from "../components/Shared/Breadcrumbs"
|
||||||
import MainLayout from "../layouts/MainLayout"
|
import MainLayout from "../layouts/MainLayout"
|
||||||
import DealImages from "../components/DealDetails/DealImages"
|
import DealImages from "../components/DealDetails/DealImages"
|
||||||
import DealDetails from "../components/DealDetails/DealDetails"
|
import DealDetails from "../components/DealDetails/DealDetails"
|
||||||
import DealDescription from "../components/DealDetails/DealDescription"
|
import DealDescription from "../components/DealDetails/DealDescription"
|
||||||
import DealComments from "../components/DealDetails/DealComments"
|
import DealComments from "../components/DealDetails/DealComments"
|
||||||
|
import DealNotice from "../components/DealDetails/DealNotice"
|
||||||
|
import SimilarDeals from "../components/DealDetails/SimilarDeals"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { getDealDetail } from "../api/deal/getDeal"
|
import { getDealDetail } from "../api/deal/getDeal"
|
||||||
import type { DealDetail } from "../models/deal/DealDetail"
|
import type { DealDetail, DealNoticeSeverity } from "../models/deal/DealDetail"
|
||||||
|
|
||||||
type DealPageProps = {
|
type DealPageProps = {
|
||||||
onRequireLogin: () => void
|
onRequireLogin: () => void
|
||||||
|
|
@ -31,10 +35,40 @@ export default function DealPage({ onRequireLogin }: DealPageProps) {
|
||||||
})()
|
})()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
const statusNotice = useMemo(() => {
|
||||||
|
if (!deal) return null
|
||||||
|
|
||||||
|
if (deal.status === "PENDING") {
|
||||||
|
return {
|
||||||
|
title: "İncelemede",
|
||||||
|
body: "Bu fırsat moderasyon incelemesinde. Onaylanınca herkese açık görünecek.",
|
||||||
|
severity: "WARNING" as DealNoticeSeverity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deal.status === "REJECTED") {
|
||||||
|
return {
|
||||||
|
title: "Reddedildi",
|
||||||
|
body: "Bu fırsat moderasyon tarafından reddedildi.",
|
||||||
|
severity: "DANGER" as DealNoticeSeverity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deal.status === "EXPIRED") {
|
||||||
|
return {
|
||||||
|
title: "Süresi doldu",
|
||||||
|
body: "Bu fırsatın süresi dolmuş olabilir. Link/ücret güncel olmayabilir.",
|
||||||
|
severity: "INFO" as DealNoticeSeverity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}, [deal])
|
||||||
|
|
||||||
if (!deal) {
|
if (!deal) {
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div className="max-w-[1400px] mx-auto px-4 py-10">
|
<div className="max-w-[1400px] mx-auto px-4 py-8">
|
||||||
<div className="rounded-3xl bg-surface border border-white/10 p-6">
|
<div className="rounded-3xl bg-surface border border-white/10 p-6">
|
||||||
<p className="text-text-muted">Yükleniyor...</p>
|
<p className="text-text-muted">Yükleniyor...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -43,49 +77,64 @@ export default function DealPage({ onRequireLogin }: DealPageProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div className="max-w-[1400px] mx-auto px-4 py-8">
|
{/* ✅ Navbar'a yapışmasın + sayfa ile aynı hizaya gelsin */}
|
||||||
{/* üst ana grid */}
|
<div className="max-w-[1400px] mx-auto px-4 pt-5 pb-2">
|
||||||
|
<Breadcrumbs breadcrumb={deal.breadcrumb} currentLabel={deal.title} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ✅ Aşağıdaki boşluğu azalttık: py-8 -> py-5 */}
|
||||||
|
<div className="max-w-[1400px] mx-auto px-4 py-0">
|
||||||
|
{statusNotice && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<DealNotice
|
||||||
|
title={statusNotice.title}
|
||||||
|
body={statusNotice.body}
|
||||||
|
severity={statusNotice.severity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deal.notice && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<DealNotice
|
||||||
|
title={deal.notice.title}
|
||||||
|
body={deal.notice.body}
|
||||||
|
severity={deal.notice.severity}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
{/* SOL: Görseller */}
|
|
||||||
<div className="lg:col-span-7">
|
<div className="lg:col-span-7">
|
||||||
<div className="rounded-3xl bg-surface border border-white/10 p-4 sm:p-5">
|
<div className="rounded-3xl bg-surface border border-white/10 p-0 sm:p-0">
|
||||||
<DealImages images={deal.images} />
|
<DealImages images={deal.images} isExpired={deal.status === "EXPIRED"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SAĞ: Detay kartı (sticky) */}
|
|
||||||
<div className="lg:col-span-5">
|
<div className="lg:col-span-5">
|
||||||
<div className="lg:sticky lg:top-24">
|
<div className="lg:sticky lg:top-24">
|
||||||
|
<DealDetails
|
||||||
<DealDetails
|
title={deal.title}
|
||||||
title={deal.title}
|
price={deal.price?.toString() ?? "-"}
|
||||||
price={deal.price?.toString() ?? "-"}
|
store={deal.seller.name}
|
||||||
store={deal.seller.name}
|
link={deal.url ?? ""}
|
||||||
link={deal.url ?? ""}
|
postedBy={deal.user.username}
|
||||||
postedBy={deal.user.username}
|
postedAgo={deal.createdAt}
|
||||||
postedAgo={deal.createdAt}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* küçük yan bilgi alanı */}
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ALT: Açıklama + Yorumlar */}
|
{/* ✅ alt taraf boşluğu azalttık: gap-6 -> gap-4 */}
|
||||||
{/* ALT: açıklama + yorumlar (tam genişlik) */}
|
<div className="lg:col-span-12 flex flex-col gap-4 mt-1">
|
||||||
<div className="lg:col-span-12 flex flex-col gap-6 mt-1">
|
<div className="rounded-3xl bg-surface border border-white/10 p-5">
|
||||||
<div className="rounded-3xl bg-surface border border-white/10 p-5">
|
<DealDescription description={deal.description} />
|
||||||
<DealDescription description={deal.description} />
|
</div>
|
||||||
</div>
|
<SimilarDeals deals={deal.similarDeals ?? []} />
|
||||||
|
|
||||||
<DealComments dealId={deal.id} onRequireLogin={onRequireLogin} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<DealComments dealId={deal.id} onRequireLogin={onRequireLogin} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
|
|
|
||||||
|
|
@ -1,115 +1,184 @@
|
||||||
// src/pages/Home.tsx
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useEffect, useState, useRef } from "react"
|
import { useSearchParams } from "react-router-dom";
|
||||||
import MainLayout from "../layouts/MainLayout"
|
import MainLayout from "../layouts/MainLayout";
|
||||||
import DealCardMain from "../components/Shared/DealCardMain"
|
import DealCardMain from "../components/Shared/DealCardMain";
|
||||||
import { getDeals } from "../api/deal/getDeal"
|
import DealsPresetTabs from "../components/Shared/DealsPresetTabs";
|
||||||
import { mapDealCardResponseToDeal } from "../adapters/responses/dealCardAdapter"
|
import { fetchPresetDeals } from "../api/deal/getDeal";
|
||||||
import { timeAgo } from "../utils/timeAgo"
|
import type { DealPreset } from "../api/deal/getDeal";
|
||||||
import type { DealCard } from "../models/deal/DealCard"
|
import type { DealCard } from "../models/deal/DealCard";
|
||||||
|
import HotDealsSidebar from "../components/Sidebar/HotDealsSidebar";
|
||||||
|
import FilterMenu from "../components/FilterMenu";
|
||||||
|
|
||||||
type HomeProps = {
|
type HomeProps = {
|
||||||
onRequireLogin: () => void
|
onRequireLogin: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LIMIT = 10;
|
||||||
|
|
||||||
|
function getPresetFromQuery(value: string | null): DealPreset {
|
||||||
|
if (value === "hot" || value === "trending") return value;
|
||||||
|
return "new";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage({ onRequireLogin }: HomeProps) {
|
export default function HomePage({ onRequireLogin }: HomeProps) {
|
||||||
const [deals, setDeals] = useState<DealCard[]>([])
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [page, setPage] = useState(1)
|
const currentPreset = useMemo(
|
||||||
const [hasMore, setHasMore] = useState(true)
|
() => getPresetFromQuery(searchParams.get("tab")),
|
||||||
const [loading, setLoading] = useState(false)
|
[searchParams]
|
||||||
const [error, setError] = useState<string | null>(null)
|
);
|
||||||
const observerRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
const [deals, setDeals] = useState<DealCard[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loadedOnce, setLoadedOnce] = useState(false);
|
||||||
|
|
||||||
|
const observerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const handlePresetChange = (next: DealPreset) => {
|
||||||
|
if (next === currentPreset) return;
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set("tab", next);
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
setHasMore(true);
|
||||||
|
setError(null);
|
||||||
|
setLoadedOnce(false);
|
||||||
|
}, [currentPreset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
|
||||||
const loadDeals = async () => {
|
const loadDeals = async () => {
|
||||||
if (loading || !hasMore) return
|
setLoading(true);
|
||||||
setLoading(true)
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deals = await getDeals(page)
|
const response = await fetchPresetDeals(currentPreset, page, LIMIT);
|
||||||
if (deals.length === 0) {
|
if (canceled) return;
|
||||||
setHasMore(false)
|
|
||||||
} else {
|
|
||||||
|
|
||||||
|
setDeals((prev) => (page === 1 ? response : [...prev, ...response]));
|
||||||
setDeals((prev) => {
|
setHasMore(response.length === LIMIT);
|
||||||
const existingIds = new Set(prev.map((d) => d.id))
|
setLoadedOnce(true);
|
||||||
const filtered = deals.filter(
|
|
||||||
(d) => !existingIds.has(d.id)
|
|
||||||
)
|
|
||||||
return [...prev, ...filtered]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? "Bir hata oluştu")
|
if (!canceled) setError(err?.message ?? "Bir hata oluştu");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (!canceled) setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
loadDeals()
|
loadDeals();
|
||||||
}, [page])
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [currentPreset, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
const hit = entries[0]?.isIntersecting;
|
||||||
setPage((prev) => prev + 1)
|
if (!hit) return;
|
||||||
}
|
|
||||||
|
if (!loadedOnce) return;
|
||||||
|
if (loading) return;
|
||||||
|
if (!hasMore) return;
|
||||||
|
if (deals.length === 0) return;
|
||||||
|
|
||||||
|
setPage((p) => p + 1);
|
||||||
},
|
},
|
||||||
{ threshold: 1 }
|
{ threshold: 1 }
|
||||||
)
|
);
|
||||||
|
|
||||||
if (observerRef.current) observer.observe(observerRef.current)
|
const el = observerRef.current;
|
||||||
return () => observer.disconnect()
|
if (el) observer.observe(el);
|
||||||
}, [hasMore, loading])
|
return () => observer.disconnect();
|
||||||
|
}, [loadedOnce, loading, hasMore, deals.length]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <p className="p-4 text-red-600">{error}</p>
|
return <p className="p-4 text-red-600">{error}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
<div className="max-w-[1400px] mx-auto px-4 py-8">
|
||||||
{/* SOL: Deal listesi */}
|
|
||||||
<div className="lg:col-span-3 space-y-4">
|
|
||||||
{deals.map((deal) => (
|
|
||||||
<DealCardMain
|
|
||||||
key={deal.id}
|
|
||||||
id={deal.id}
|
|
||||||
image={deal.imageUrl || "/placeholder.png"}
|
|
||||||
title={deal.title}
|
|
||||||
price={deal.price ? `${deal.price}₺` : ""}
|
|
||||||
store={deal.seller.name}
|
|
||||||
postedBy={deal.user?.username ?? "unknown"}
|
|
||||||
score={deal.score}
|
|
||||||
comments={deal.commentsCount}
|
|
||||||
postedAgo={timeAgo(deal.createdAt)}
|
|
||||||
myVote={deal.myVote}
|
|
||||||
onRequireLogin={onRequireLogin}
|
|
||||||
/>
|
|
||||||
|
|
||||||
))}
|
|
||||||
|
|
||||||
{loading && (
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
<p className="text-center py-4">Yükleniyor...</p>
|
<div className="lg:col-span-3 flex justify-between mb-3">
|
||||||
)}
|
{/* Sol tarafta DealsPresetTabs */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<DealsPresetTabs currentPreset={currentPreset} onSelect={handlePresetChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{!hasMore && (
|
{/* Sağ tarafta FilterMenu */}
|
||||||
<p className="text-center py-4 text-muted-foreground">
|
<div className="flex justify-end items-center">
|
||||||
Tüm fırsatlar yüklendi.
|
<FilterMenu onFilterChange={(filters) => console.log(filters)} />
|
||||||
</p>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
<div ref={observerRef} className="h-8" />
|
|
||||||
|
<aside className="hidden lg:block rounded-lg ">
|
||||||
|
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{/* 2) Deals ve Sidebar */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`space-y-2 min-h-[60vh] transition-opacity duration-150 ${
|
||||||
|
loading && deals.length > 0 ? "opacity-80" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!loadedOnce && loading && deals.length === 0 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{Array.from({ length: 3 }).map((_, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="h-32 rounded-2xl border border-border bg-surface-2 animate-pulse"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : deals.length === 0 && loadedOnce && !loading ? (
|
||||||
|
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
||||||
|
<div className="text-sm font-semibold text-text">Henüz fırsat yok</div>
|
||||||
|
<div className="text-xs text-text-muted mt-1">
|
||||||
|
Seçili sekmede henüz paylaşım bulunmuyor.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
deals.map((deal) => (
|
||||||
|
<DealCardMain key={deal.id} deal={deal} onRequireLogin={onRequireLogin} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && deals.length > 0 && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/70 text-text">
|
||||||
|
<p className="text-sm font-semibold">Yükleniyor...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && !loading && deals.length > 0 && (
|
||||||
|
<p className="text-center py-4 text-muted-foreground">Tüm fırsatlar yüklendi.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={observerRef} className="h-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="hidden lg:block rounded-lg ">
|
||||||
|
<HotDealsSidebar />
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SAĞ: sidebar */}
|
|
||||||
<aside className="hidden lg:block bg-surface/50 rounded-lg p-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Yan içerik / filtre / reklam alanı
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
</div>
|
</div>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,22 @@ import DealCardMain from "../components/Shared/DealCardMain"
|
||||||
import CommentCard from "../components/Profile/CommentCard"
|
import CommentCard from "../components/Profile/CommentCard"
|
||||||
import ProfileHeader from "../components/Profile/ProfileHeader"
|
import ProfileHeader from "../components/Profile/ProfileHeader"
|
||||||
import { fetchUserProfile } from "../services/userService"
|
import { fetchUserProfile } from "../services/userService"
|
||||||
|
import { fetchUserDeals } from "../services/userDealsService"
|
||||||
import { timeAgo } from "../utils/timeAgo"
|
import { timeAgo } from "../utils/timeAgo"
|
||||||
|
|
||||||
|
import type { DealCard } from "../models/deal/DealCard"
|
||||||
import type { UserProfile } from "../models/user/UserProfile"
|
import type { UserProfile } from "../models/user/UserProfile"
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { userName } = useParams<{ userName: string }>()
|
const { userName } = useParams<{ userName: string }>()
|
||||||
const [activeTab, setActiveTab] = useState<"deals" | "comments">("deals")
|
const [activeTab, setActiveTab] = useState<"deals" | "comments">("deals")
|
||||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
|
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
|
||||||
|
const [userDeals, setUserDeals] = useState<DealCard[]>([])
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [dealsLoading, setDealsLoading] = useState(true)
|
||||||
|
const [dealsError, setDealsError] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userName) return
|
if (!userName) return
|
||||||
|
|
@ -37,15 +42,33 @@ export default function ProfilePage() {
|
||||||
loadUser()
|
loadUser()
|
||||||
}, [userName])
|
}, [userName])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userName) return
|
||||||
|
|
||||||
|
const loadDeals = async () => {
|
||||||
|
setDealsLoading(true)
|
||||||
|
setDealsError(null)
|
||||||
|
try {
|
||||||
|
const deals = await fetchUserDeals(userName)
|
||||||
|
setUserDeals(deals)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err)
|
||||||
|
setDealsError(err.message || "Paylaşımlar alınamadı")
|
||||||
|
} finally {
|
||||||
|
setDealsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDeals()
|
||||||
|
}, [userName])
|
||||||
|
|
||||||
if (loading) return <p className="p-6 text-center text-text-muted">Yükleniyor...</p>
|
if (loading) return <p className="p-6 text-center text-text-muted">Yükleniyor...</p>
|
||||||
if (error) return <p className="p-6 text-center text-danger">{error}</p>
|
if (error) return <p className="p-6 text-center text-danger">{error}</p>
|
||||||
if (!userProfile) return <p className="p-6 text-center text-text-muted">Kullanıcı bulunamadı.</p>
|
if (!userProfile) return <p className="p-6 text-center text-text-muted">Kullanıcı bulunamadı.</p>
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
||||||
{/* ÜST: profil header */}
|
|
||||||
<ProfileHeader
|
<ProfileHeader
|
||||||
username={userProfile.user.username}
|
username={userProfile.user.username}
|
||||||
avatarUrl={userProfile.user.avatarUrl ?? undefined}
|
avatarUrl={userProfile.user.avatarUrl ?? undefined}
|
||||||
|
|
@ -54,7 +77,6 @@ export default function ProfilePage() {
|
||||||
totalComments={userProfile.stats.totalComments}
|
totalComments={userProfile.stats.totalComments}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* TAB BAR */}
|
|
||||||
<div className="rounded-2xl bg-surface border border-border p-2 flex items-center justify-center gap-2">
|
<div className="rounded-2xl bg-surface border border-border p-2 flex items-center justify-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -67,9 +89,7 @@ export default function ProfilePage() {
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
Paylaşımlar
|
Paylaşımlar
|
||||||
<span className="ml-2 text-xs text-text-muted">
|
<span className="ml-2 text-xs text-text-muted">{userDeals.length}</span>
|
||||||
{userProfile.deals?.length ?? 0}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
@ -89,27 +109,17 @@ export default function ProfilePage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* İÇERİK */}
|
|
||||||
<div className="min-h-[300px]">
|
<div className="min-h-[300px]">
|
||||||
{activeTab === "deals" ? (
|
{activeTab === "deals" ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{userProfile.deals?.length > 0 ? (
|
{dealsLoading ? (
|
||||||
userProfile.deals.map((deal) => (
|
<p className="text-center py-4 text-text-muted">Yükleniyor...</p>
|
||||||
<DealCardMain
|
) : dealsError ? (
|
||||||
key={deal.id}
|
<p className="text-center py-4 text-danger">{dealsError}</p>
|
||||||
id={deal.id}
|
) : userDeals.length > 0 ? (
|
||||||
image={deal.imageUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-deal.png`}
|
userDeals.map((deal) => (
|
||||||
title={deal.title}
|
<DealCardMain key={deal.id} deal={deal} onRequireLogin={()=>{}} />
|
||||||
price={deal.price != null ? `${deal.price}₺` : ""}
|
|
||||||
|
|
||||||
store={deal.seller?.name ?? ""}
|
|
||||||
postedBy={deal.user?.username ?? "unknown"}
|
|
||||||
score={deal.score}
|
|
||||||
comments={deal.commentsCount}
|
|
||||||
postedAgo={timeAgo(deal.createdAt)}
|
|
||||||
myVote={deal.myVote}
|
|
||||||
onRequireLogin={() => {}}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
||||||
|
|
@ -123,9 +133,7 @@ export default function ProfilePage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{userProfile.comments?.length > 0 ? (
|
{userProfile.comments?.length > 0 ? (
|
||||||
userProfile.comments.map((c) => (
|
userProfile.comments.map((c) => <CommentCard key={c.id} comment={c} />)
|
||||||
<CommentCard key={c.id} comment={c} />
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
||||||
<div className="text-sm font-semibold text-text">Henüz yorum yok</div>
|
<div className="text-sm font-semibold text-text">Henüz yorum yok</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState, useRef } from "react"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
import MainLayout from "../layouts/MainLayout"
|
import MainLayout from "../layouts/MainLayout"
|
||||||
import DealCardMain from "../components/Shared/DealCardMain"
|
import DealCardMain from "../components/Shared/DealCardMain"
|
||||||
import { timeAgo } from "../utils/timeAgo"
|
|
||||||
import { searchDeals } from "../api/deal/searchDeal"
|
import { searchDeals } from "../api/deal/searchDeal"
|
||||||
|
|
||||||
export default function SearchPage({ onRequireLogin }: { onRequireLogin: () => void }) {
|
export default function SearchPage({ onRequireLogin }: { onRequireLogin: () => void }) {
|
||||||
|
|
@ -67,19 +67,8 @@ export default function SearchPage({ onRequireLogin }: { onRequireLogin: () => v
|
||||||
|
|
||||||
|
|
||||||
{deals.map((deal) => (
|
{deals.map((deal) => (
|
||||||
<DealCardMain
|
<DealCardMain key={deal.id} deal={deal} onRequireLogin={onRequireLogin} />
|
||||||
key={deal.id}
|
|
||||||
id={deal.id}
|
|
||||||
image={deal.imageUrl || "/placeholder.png"}
|
|
||||||
title={deal.title}
|
|
||||||
price={deal.price ? `${deal.price}₺` : ""}
|
|
||||||
store={deal.seller.name}
|
|
||||||
postedBy={deal.user?.username ?? "unknown"}
|
|
||||||
score={deal.score}
|
|
||||||
comments={deal.commentsCount}
|
|
||||||
postedAgo={timeAgo(deal.createdAt)}
|
|
||||||
onRequireLogin={onRequireLogin}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{loading && <p className="text-center py-4">Yükleniyor...</p>}
|
{loading && <p className="text-center py-4">Yükleniyor...</p>}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { useState } from "react"
|
|
||||||
import MainLayout from "../layouts/MainLayout"
|
|
||||||
import { createDeal } from "../api/deal/newDeal"
|
|
||||||
|
|
||||||
export default function SubmitDealPage() {
|
|
||||||
const [title, setTitle] = useState("")
|
|
||||||
const [description, setDescription] = useState("")
|
|
||||||
const [url, setUrl] = useState("")
|
|
||||||
const [imageUrl, setImageUrl] = useState("")
|
|
||||||
const [price, setPrice] = useState("")
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const token = localStorage.getItem("token")
|
|
||||||
if (!token) {
|
|
||||||
alert("Fırsat eklemek için giriş yapmalısın.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createDeal( {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
url,
|
|
||||||
imageUrl,
|
|
||||||
price: parseFloat(price),
|
|
||||||
})
|
|
||||||
|
|
||||||
alert("Fırsat başarıyla gönderildi.")
|
|
||||||
setTitle("")
|
|
||||||
setDescription("")
|
|
||||||
setUrl("")
|
|
||||||
setImageUrl("")
|
|
||||||
setPrice("")
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || "Sunucu hatası.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainLayout>
|
|
||||||
<div className="max-w-xl mx-auto bg-surface/50 p-6 rounded-lg shadow-sm mt-8">
|
|
||||||
<h1 className="text-2xl font-semibold mb-4 text-primary">Fırsat Yolla</h1>
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Başlık"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
className="border rounded-md px-3 py-2"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
placeholder="Açıklama"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
className="border rounded-md px-3 py-2 h-24"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
placeholder="Ürün Linki"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
className="border rounded-md px-3 py-2"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
placeholder="Görsel URL"
|
|
||||||
value={imageUrl}
|
|
||||||
onChange={(e) => setImageUrl(e.target.value)}
|
|
||||||
className="border rounded-md px-3 py-2"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="Fiyat"
|
|
||||||
value={price}
|
|
||||||
onChange={(e) => setPrice(e.target.value)}
|
|
||||||
className="border rounded-md px-3 py-2"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="bg-primary text-white py-2 rounded-md hover:bg-primary/90 transition"
|
|
||||||
>
|
|
||||||
Gönder
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</MainLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
12
src/services/userDealsService.ts
Normal file
12
src/services/userDealsService.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { DealCard } from "../models/deal/DealCard"
|
||||||
|
import { getUserDeals } from "../api/user/getUserDeals"
|
||||||
|
|
||||||
|
export async function fetchUserDeals(userName: string): Promise<DealCard[]> {
|
||||||
|
try {
|
||||||
|
return await getUserDeals(userName)
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message || "Paylaşımlar alınamadı"
|
||||||
|
console.error("Kullanıcı paylaşımlarını alırken hata:", err)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/utils/heat.ts
Normal file
18
src/utils/heat.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
export function scoreToHeat(score: number): { degree: number; color: string } {
|
||||||
|
const s = Number.isFinite(score) ? score : 0
|
||||||
|
const degree = Math.max(0, Math.min(150, Math.round(s)))
|
||||||
|
const t = degree / 150 // 0..1
|
||||||
|
|
||||||
|
// Daha hızlı kırmızıya geçiş ama yine de çok yoğun olmayacak
|
||||||
|
const hue = 28 - 20 * t // Kırmızıya geçişin hızını artırdık
|
||||||
|
const sat = 92
|
||||||
|
const light = 52 - 6 * t
|
||||||
|
|
||||||
|
// Hue değeri 0'a yaklaşırken ışıklığı biraz daha artırıyoruz
|
||||||
|
const finalLight = t > 0.85 ? 58 : light // Son dilimde ışıklığı artırıyoruz
|
||||||
|
|
||||||
|
return {
|
||||||
|
degree,
|
||||||
|
color: `hsl(${hue}deg, ${sat}%, ${finalLight}%)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user