bayağı şey eklendi breadcrumb kategori menü vs

This commit is contained in:
cureb 2026-01-25 23:03:16 +00:00
parent a48d32fdec
commit 84e1ef9ee6
67 changed files with 2910 additions and 814 deletions

17
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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
<Route path="/"
path="/deal/:id" element={<HomePage onRequireLogin={() => setShowLoginModal(true)} />}
element={<DealPage onRequireLogin={() => setShowLoginModal(true)} />} />
/>
<Route
path="/search"
element={<SearchPage onRequireLogin={() => setShowLoginModal(true)} />}
/>
{/* DealPage route */}
<Route
path="/deal/:id"
element={<DealPage onRequireLogin={() => setShowLoginModal(true)} />}
/>
<Route path="/create-deal" element={<CreateDealPage />} /> {/* SearchPage route */}
<Route path="/account" element={<AccountSettingsPage />} /> <Route
<Route path="/user/:userName" element={<ProfilePage />} /> path="/search"
element={<SearchPage onRequireLogin={() => setShowLoginModal(true)} />}
/>
</Routes> {/* CategoryPage route */}
</BrowserRouter> <Route
path="/category/:categorySlug"
element={<CategoryPage />}
/>
{/* CreateDealPage route */}
<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>
) );
} }

View File

@ -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,

View File

@ -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,
} }
} }

View File

@ -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,
}
} }
}

View File

@ -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)

View File

@ -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)
} }

View File

@ -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)

View File

@ -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)

View File

@ -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

View 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");
}
}

View File

@ -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)
}
}

View File

@ -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

View 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
}

View File

@ -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)
} }

View File

@ -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)
}

View File

@ -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
@ -58,9 +78,9 @@ export type DealDetailResponse = {
avatarUrl: string | null avatarUrl: string | null
} }
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

View File

@ -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

View File

@ -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)
} }

View File

@ -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 }
}

View File

@ -1,15 +1,18 @@
// 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(
err.response?.data?.message || "Kullanıcı bilgileri alınamadı" err.response?.data?.message || "Kullanıcı bilgileri alınamadı"
) )
} }
} }

View 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)
}

View File

@ -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
} }

View 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>
</>
)
}

View File

@ -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">

View File

@ -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>

View File

@ -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>
) )
} }

View 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>
)
}

View 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>
)
}

View 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;

View 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>
)
}

View File

@ -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>
); );

View File

@ -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>
) );
} }

View 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>
);
}

View File

@ -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>

View File

@ -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)} />}

View 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>
)
}

View File

@ -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>
) )
} }

View 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>
)
}

View 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;

View 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>
);
}

View File

@ -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
View 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
View 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);
}

View File

@ -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;
} }
@ -70,4 +109,4 @@ a {
a:hover { a:hover {
color: var(--color-primary); color: var(--color-primary);
} }

View File

@ -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 }
}

View File

@ -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])
}

View File

@ -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" // pathi düzelt
import { DUMMY_CATEGORIES } from "../data/categories" // pathi 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 */}

View File

@ -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(

View File

@ -0,0 +1,8 @@
// src/models/deal/Breadcrumb.ts
export type BreadcrumbItem = {
id: number
name: string
slug: string
}
export type Breadcrumb = BreadcrumbItem[]

View 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
}

View 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
}

View File

@ -5,5 +5,5 @@ export type Comment = {
text: string text: string
createdAt: string createdAt: string
user:PublicUserSummary user:PublicUserSummary
deal:DealCard deal?: DealCard | null
} }

View File

@ -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
} }

View 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
}

View File

@ -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
View 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>
);
}

View File

@ -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,
})) }))

View File

@ -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>

View File

@ -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 && (
<p className="text-center py-4">Yükleniyor...</p>
)}
{!hasMore && ( <div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<p className="text-center py-4 text-muted-foreground"> <div className="lg:col-span-3 flex justify-between mb-3">
Tüm fırsatlar yüklendi. {/* Sol tarafta DealsPresetTabs */}
</p> <div className="flex items-center">
)} <DealsPresetTabs currentPreset={currentPreset} onSelect={handlePresetChange} />
</div>
<div ref={observerRef} className="h-8" /> {/* Sağ tarafta FilterMenu */}
<div className="flex justify-end items-center">
<FilterMenu onFilterChange={(filters) => console.log(filters)} />
</div>
</div>
<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>
) );
} }

View File

@ -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>

View File

@ -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>}

View File

@ -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>
)
}

View 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
View 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}%)`,
}
}