created image array

ui overhaul
This commit is contained in:
cureb 2026-01-23 17:28:05 +00:00
parent c06b4b8211
commit a48d32fdec
55 changed files with 1652 additions and 814 deletions

View File

@ -1,4 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />

10
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.1", "axios": "^1.13.1",
"lucide-react": "^0.562.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4" "react-router-dom": "^7.9.4"
@ -3703,6 +3704,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",

View File

@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.16", "@tailwindcss/vite": "^4.1.16",
"axios": "^1.13.1", "axios": "^1.13.1",
"lucide-react": "^0.562.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router-dom": "^7.9.4" "react-router-dom": "^7.9.4"

View File

@ -1,6 +1,6 @@
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 HomePage from "./pages/HomePage" import HomePage from "./pages/HomePage"
import DealPage from "./pages/DealDetailsPage" import DealPage from "./pages/DealDetailsPage"
@ -16,6 +16,7 @@ export default function App() {
const [showLoginModal, setShowLoginModal] = useState(false) const [showLoginModal, setShowLoginModal] = useState(false)
return ( return (
<ErrorBoundary>
<BrowserRouter> <BrowserRouter>
{showLoginModal && ( {showLoginModal && (
<LoginModal <LoginModal
@ -42,5 +43,6 @@ export default function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ErrorBoundary>
) )
} }

View File

@ -1,18 +1,22 @@
// adapters/requests/dealCreateAdapter.ts
import type { DealDraft } from "../../models/DealDraft" import type { DealDraft } from "../../models/DealDraft"
import type { CreateDealRequest } from "../../api/deal/types"
export function mapDealDraftToCreateRequest( export function mapDealDraftToCreateRequest(draft: DealDraft): FormData {
draft: DealDraft const fd = new FormData()
): CreateDealRequest {
return {
title: draft.title,
description: draft.description,
price: draft.price,
imageUrl: draft.imageUrl,
url: draft.url || undefined, fd.append("title", draft.title)
if (draft.description) fd.append("description", draft.description)
if (draft.url) fd.append("url", draft.url)
sellerName:draft.seller.name if (draft.price != null) fd.append("price", String(draft.price))
}
if (draft.customCompany) fd.append("sellerName", draft.customCompany)
// files
;(draft.images ?? []).slice(0, 5).forEach((f) => {
fd.append("images", f) // field adı backend'deki upload.array("images", 5) ile aynı
})
return fd
} }

View File

@ -1,8 +0,0 @@
import type { Seller } from "../../models/seller/Seller";
import type { SellerFromLookupRequest } from "../../api/seller/types";
export function mapSellerFromLookupRequest(seller:Seller):SellerFromLookupRequest{
return{
url:seller.url
}
}

View File

@ -17,6 +17,8 @@ export function mapDealCardResponseToDeal(
saleType: api.saleType, saleType: api.saleType,
affiliateType: api.affiliateType, affiliateType: api.affiliateType,
myVote:api.myVote,
createdAt: api.createdAt, createdAt: api.createdAt,
updatedAt:api.updatedAt, updatedAt:api.updatedAt,
user: { user: {

View File

@ -1,13 +1,10 @@
// src/api/auth/login.ts // src/api/auth/login.ts
import instance from "../axiosInstance" import instance from "../axiosInstance"
import type { LoginInput } from "./types"
export async function login(email: string, password: string) {
try {
const { data } = await instance.post("/auth/login", { email, password }) export async function login(input: LoginInput) {
return data // { token, user } const { data } = await instance.post("/auth/login", input)
} catch (error: any) { return data
const message = error.response?.data?.message || "Giriş başarısız"
console.error("Login hatası:", message)
throw new Error(message)
}
} }

4
src/api/auth/types.ts Normal file
View File

@ -0,0 +1,4 @@
export type LoginInput = {
email: string
password: string
}

View File

@ -1,9 +1,11 @@
// src/api/deal/commentApi.ts // src/api/deal/commentApi.ts
import instance from "../axiosInstance" import instance from "../axiosInstance"
export async function getComments(dealId: number) { import type { Comment } from "../../models"
export async function getComments(dealId: number): Promise<Comment[]> {
try { try {
const { data } = await instance.get(`/comments/${dealId}`) const { data } = await instance.get<Comment[]>(`/comments/${dealId}`)
return data return 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ı"

View File

@ -1,17 +1,16 @@
// src/api/deal/dealApi.ts // src/api/deal/dealApi.ts
import instance from "../axiosInstance" import instance from "../axiosInstance"
import type { DealCardResponse,DealDetailResponse} from "./types" import type { DealCard } from "../../models/deal/DealCard"
import type { DealDetail } from "../../models/deal/DealDetail"
export async function getDeals( export async function getDeals(page = 1): Promise<DealCard[]> {
page = 1 const { data } = await instance.get<{ results: DealCard[] }>(
): Promise<DealCardResponse[]> { `/deals?page=${page}`
const { data } = await instance.get(`/deals?page=${page}`) )
console.log(data.results)
return data.results return data.results
} }
export async function getDealDetail(id: number): Promise<DealDetail> {
export async function getDealDetail( const { data } = await instance.get<DealDetail>(`/deals/${id}`)
id: number
): Promise<DealDetailResponse> {
const { data } = await instance.get(`/deals/${id}`)
return data return data
} }

View File

@ -1,21 +1,18 @@
// src/api/deal/createDeal.ts // src/api/deal/createDeal.ts
import instance from "../axiosInstance" import instance from "../axiosInstance"
type DealData = { export async function createDeal(formData: FormData) {
title: string
description?: string
url?: string
imageUrl?: string
customCompany?: string
price?: number
}
export async function createDeal(dealData: DealData) {
try { try {
const { data } = await instance.post("/deals", dealData)
const { data } = await instance.post("/deals", formData, {
// Axios FormData gönderirken header'ı genelde kendi ayarlar.
// Ama bazı kurulumlarda gerekebilir:
headers: { "Content-Type": "multipart/form-data" },
})
return data return data
} catch (error: any) { } catch (error: any) {
const message = error.response?.data?.error || "Fırsat eklenemedi" const message =
error.response?.data?.error || "Fırsat eklenemedi"
console.error("Fırsat oluşturma hatası:", message) console.error("Fırsat oluşturma hatası:", message)
throw new Error(message) throw new Error(message)
} }

View File

@ -1,14 +1,17 @@
import instance from "../axiosInstance" import instance from "../axiosInstance"
import type { DealCard } from "../../models/deal/DealCard"
export async function searchDeals(query: string, page = 1) {
try { export async function searchDeals(
const { data } = await instance.get(`/deals/search`, { query: string,
page = 1
): Promise<DealCard[]> {
const { data } = await instance.get<{ results: DealCard[] }>(
`/deals/`,
{
params: { q: query, page }, params: { q: query, page },
})
// backend response { results, total, totalPages } formatındaysa:
return data.results
} catch (error) {
console.error("Deal arama hatası:", error)
throw error
} }
)
return data.results
} }

View File

@ -6,6 +6,7 @@ export type DealCardResponse = {
description: string // DB null → backend "" yapar description: string // DB null → backend "" yapar
price: number | null // fiyat yoksa bilinçli null price: number | null // fiyat yoksa bilinçli null
myVote: -1 | 0 | 1
score: number score: number
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED" status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED"
saleType: "ONLINE" | "OFFLINE" | "CODE" saleType: "ONLINE" | "OFFLINE" | "CODE"
@ -87,7 +88,7 @@ export type CreateDealRequest = {
description?: string description?: string
price?: number price?: number
imageUrl: string imageUrl: string
images?:File[]
// online deal // online deal
url?: string url?: string

View File

@ -1,9 +1,9 @@
// src/api/deal/voteDeal.ts // src/api/deal/voteDeal.ts
import instance from "../axiosInstance" import instance from "../axiosInstance"
export async function voteDeal(dealId: number, type: "UP" | "DOWN") { export async function voteDeal(dealId: number, type: 1 | 0 | -1) {
try { try {
const { data } = await instance.post("/deal-votes", { const { data } = await instance.post("/vote", {
dealId, dealId,
voteType: type, voteType: type,
}) })

View File

@ -1,13 +1,12 @@
import type { SellerFromLookupResponse,SellerFromLookupRequest } from "./types" import type { SellerLookupInput } from "./types"
import type { Seller } from "../../models/seller/Seller"
import instance from "../axiosInstance" import instance from "../axiosInstance"
export async function lookupSellerFromLink(seller:SellerFromLookupRequest) :Promise<SellerFromLookupResponse>{ export async function lookupSellerFromLink(input: SellerLookupInput): Promise<Seller> {
const { data } = await instance.post( const { data } = await instance.post<Seller>("/seller/from-link", input)
"/seller/from-link",
{ seller }
)
return data return data
} }

View File

@ -4,6 +4,6 @@ export type SellerFromLookupResponse= {
} }
export type SellerFromLookupRequest={ export type SellerLookupInput={
url:string|null url:string|null
} }

View File

@ -1,12 +1,15 @@
// 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"
export async function getUser(userName: string) { export async function getUser(userName: string): Promise<UserProfile> {
try { try {
const res = await instance.get(`/user/${userName}`) const { data } = await instance.get<UserProfile>(`/user/${userName}`)
return res.data // { user, deals, comments } return 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(err.response?.data?.message || "Kullanıcı bilgileri alınamadı") throw new Error(
err.response?.data?.message || "Kullanıcı bilgileri alınamadı"
)
} }
} }

View File

@ -2,7 +2,7 @@ import { useState } from "react"
import { useAuth } from "../../context/AuthContext" import { useAuth } from "../../context/AuthContext"
import { login as loginApi } from "../../api/auth/login" import { login as loginApi } from "../../api/auth/login"
import { register as registerApi } from "../../api/auth/register" import { register as registerApi } from "../../api/auth/register"
import type { LoginInput } from "../../api/auth/types"
type LoginModalProps = { type LoginModalProps = {
onClose: () => void onClose: () => void
} }
@ -20,7 +20,12 @@ export default function LoginModal({ onClose }: LoginModalProps) {
const data = await registerApi(username, email, password) const data = await registerApi(username, email, password)
login(data.user, data.token) login(data.user, data.token)
} else { } else {
const data = await loginApi(email, password)
const input: LoginInput = {
email,
password,
}
const data = await loginApi(input)
login(data.user, data.token) login(data.user, data.token)
} }
onClose() onClose()

View File

@ -1,3 +1,4 @@
import { useMemo, useRef } from "react"
import type { DealDraft } from "../../models/DealDraft" import type { DealDraft } from "../../models/DealDraft"
type Props = { type Props = {
@ -7,13 +8,47 @@ type Props = {
onSubmit: () => void onSubmit: () => void
} }
export default function DealDetailsStep({ const MAX_IMAGES = 5
data,
onChange, export default function DealDetailsStep({ data, onChange, onBack, onSubmit }: Props) {
onBack, const hasDetectedCompany = data?.seller?.id !== -1
onSubmit, const fileInputRef = useRef<HTMLInputElement | null>(null)
}: Props) {
const hasDetectedCompany = Boolean(data.sellerId) const images = data.images ?? []
const remaining = MAX_IMAGES - images.length
const previews = useMemo(() => {
return images.map((f) => ({ file: f, url: URL.createObjectURL(f) }))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [images])
function openFileDialog() {
fileInputRef.current?.click()
}
function onPickFiles(fileList: FileList | null) {
if (!fileList) return
const picked = Array.from(fileList).filter((f) => f.type.startsWith("image/"))
if (picked.length === 0) return
const next = [...images, ...picked].slice(0, MAX_IMAGES)
onChange({ ...data, images: next })
if (fileInputRef.current) fileInputRef.current.value = ""
}
function removeImage(index: number) {
const next = images.filter((_, i) => i !== index)
onChange({ ...data, images: next })
}
function moveImage(from: number, to: number) {
if (to < 0 || to >= images.length) return
const next = [...images]
const [item] = next.splice(from, 1)
next.splice(to, 0, item)
onChange({ ...data, images: next })
}
return ( return (
<form <form
@ -21,31 +56,135 @@ export default function DealDetailsStep({
e.preventDefault() e.preventDefault()
onSubmit() onSubmit()
}} }}
className="max-w-2xl mx-auto flex flex-col gap-6" className="max-w-5xl mx-auto px-4"
> >
{/* BAŞLIK */} {/* Outer card */}
<div className="rounded-3xl border border-border bg-surface shadow-lg p-4 sm:p-6">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT: Photos */}
<section className="lg:col-span-5">
<div className="rounded-2xl border border-border bg-surface p-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div> <div>
<h2 className="text-lg font-medium mb-1"> <h2 className="text-base font-semibold text-text">Fotoğraflar</h2>
Fırsat Detayları
</h2>
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">
En fazla {MAX_IMAGES} fotoğraf ekleyebilirsiniz.
</p>
</div>
<button
type="button"
onClick={openFileDialog}
disabled={remaining <= 0}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-border bg-surface-2 px-3 py-2 text-sm font-semibold text-text hover:border-border/70 disabled:opacity-50 disabled:cursor-not-allowed"
>
Fotoğraf ekle
<span className="text-xs text-text-muted">({remaining})</span>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={(e) => onPickFiles(e.target.files)}
className="hidden"
/>
</div>
{/* Dropzone */}
<button
type="button"
onClick={openFileDialog}
disabled={remaining <= 0}
className="mt-4 w-full rounded-2xl border border-dashed border-border bg-background p-5 text-left hover:bg-surface-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="text-sm font-semibold text-text">
Sürükle-bırak veya tıkla
</div>
<div className="text-xs text-text-muted mt-1">
PNG, JPG, WEBP desteklenir.
</div>
</button>
{/* Preview */}
{previews.length > 0 ? (
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-3">
{previews.map((p, idx) => (
<div
key={p.url}
className="relative overflow-hidden rounded-2xl border border-border bg-surface-2"
>
<img
src={p.url}
alt={`Seçilen görsel ${idx + 1}`}
className="h-28 w-full object-cover"
onLoad={() => URL.revokeObjectURL(p.url)}
/>
{/* Top bar */}
<div className="absolute inset-x-0 top-0 p-2 flex items-center justify-between">
<span className="text-[11px] font-semibold text-text bg-background/80 border border-border rounded-full px-2 py-1 backdrop-blur">
{idx + 1}
</span>
<button
type="button"
onClick={() => removeImage(idx)}
className="text-[11px] font-semibold text-text bg-background/80 border border-border rounded-full px-2 py-1 hover:bg-background backdrop-blur"
>
Kaldır
</button>
</div>
{/* Bottom bar: order */}
<div className="absolute inset-x-0 bottom-0 p-2 flex gap-2">
<button
type="button"
onClick={() => moveImage(idx, idx - 1)}
disabled={idx === 0}
className="flex-1 rounded-full border border-border bg-background/80 text-text text-xs py-1 hover:bg-background disabled:opacity-50 disabled:cursor-not-allowed backdrop-blur"
>
</button>
<button
type="button"
onClick={() => moveImage(idx, idx + 1)}
disabled={idx === previews.length - 1}
className="flex-1 rounded-full border border-border bg-background/80 text-text text-xs py-1 hover:bg-background disabled:opacity-50 disabled:cursor-not-allowed backdrop-blur"
>
</button>
</div>
</div>
))}
</div>
) : (
<p className="mt-4 text-sm text-text-muted">Henüz fotoğraf eklemediniz.</p>
)}
</div>
</section>
{/* RIGHT: Form */}
<section className="lg:col-span-7">
<div className="rounded-2xl border border-border bg-surface p-5">
<div className="mb-5">
<h2 className="text-lg font-semibold text-text">Fırsat Detayları</h2>
<p className="text-sm text-text-muted mt-1">
Fırsata ait temel bilgileri aşağıdaki alanlara giriniz. Fırsata ait temel bilgileri aşağıdaki alanlara giriniz.
</p> </p>
</div> </div>
{/* BAŞLIK */} <div className="flex flex-col gap-4">
{/* Title */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium"> <label className="text-sm font-semibold text-text">Fırsat başlığı</label>
Fırsat başlığı
</label>
<input <input
type="text" type="text"
placeholder="Örn: %40 indirimli spor ayakkabı" placeholder="Örn: %40 indirimli spor ayakkabı"
value={data.title} value={data.title}
onChange={(e) => onChange={(e) => onChange({ ...data, title: e.target.value })}
onChange({ ...data, title: e.target.value }) className="rounded-xl border border-border bg-surface-2 px-3 py-2 text-text placeholder:text-text-muted outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)]"
}
className="border rounded-md px-3 py-2"
required required
/> />
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
@ -53,37 +192,31 @@ export default function DealDetailsStep({
</p> </p>
</div> </div>
{/* AÇIKLAMA */} {/* Description */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium"> <label className="text-sm font-semibold text-text">ıklama</label>
ıklama
</label>
<textarea <textarea
placeholder="İndirim koşulları, geçerlilik süresi veya ek bilgiler..." placeholder="İndirim koşulları, geçerlilik süresi veya ek bilgiler..."
value={data.description ?? ""} value={data.description ?? ""}
onChange={(e) => onChange={(e) => onChange({ ...data, description: e.target.value })}
onChange({ ...data, description: e.target.value }) className="h-28 resize-none rounded-xl border border-border bg-surface-2 px-3 py-2 text-text placeholder:text-text-muted outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)]"
}
className="border rounded-md px-3 py-2 h-28 resize-none"
/> />
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
İsteğe bağlıdır. Fırsat hakkında ek bilgi verebilirsiniz. İsteğe bağlıdır. Fırsat hakkında ek bilgi verebilirsiniz.
</p> </p>
</div> </div>
{/* SATICI */} {/* Seller */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium"> <label className="text-sm font-semibold text-text">Satıcı bilgisi</label>
Satıcı bilgisi
</label>
{hasDetectedCompany ? ( {hasDetectedCompany ? (
<> <>
<input <input
type="text" type="text"
value={data.sellerName ?? ""} value={data.seller.name ?? ""}
disabled disabled
className="border rounded-md px-3 py-2 bg-gray-100 text-gray-700 cursor-not-allowed" className="rounded-xl border border-border bg-background px-3 py-2 text-text-muted cursor-not-allowed"
/> />
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
Satıcı bilgisi ürün bağlantısından otomatik olarak algılanmıştır. Satıcı bilgisi ürün bağlantısından otomatik olarak algılanmıştır.
@ -95,81 +228,56 @@ export default function DealDetailsStep({
type="text" type="text"
placeholder="Satıcı adını giriniz" placeholder="Satıcı adını giriniz"
value={data.customCompany ?? ""} value={data.customCompany ?? ""}
onChange={(e) => onChange={(e) => onChange({ ...data, customCompany: e.target.value })}
onChange({ className="rounded-xl border border-border bg-surface-2 px-3 py-2 text-text placeholder:text-text-muted outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)]"
...data,
customCompany: e.target.value,
})
}
className="border rounded-md px-3 py-2"
/> />
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
Satıcı bilgisi otomatik olarak algılanamazsa manuel olarak girilebilir. Satıcı bilgisi otomatik algılanamazsa manuel girilebilir.
</p> </p>
</> </>
)} )}
</div> </div>
{/* GÖRSEL */} {/* Price */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-sm font-medium"> <label className="text-sm font-semibold text-text">Fiyat</label>
Görsel bağlantısı
</label>
<input <input
type="url" inputMode="numeric"
placeholder="https://www.siteadi.com/gorsel.jpg"
value={data.imageUrl ?? ""}
onChange={(e) =>
onChange({ ...data, imageUrl: e.target.value })
}
className="border rounded-md px-3 py-2"
/>
<p className="text-xs text-text-muted">
Ürünü temsil eden bir görsel bağlantısı ekleyebilirsiniz.
</p>
</div>
{/* FİYAT */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium">
Fiyat
</label>
<input
type="number"
placeholder="Örn: 999" placeholder="Örn: 999"
value={data.price ?? ""} value={data.price ?? ""}
onChange={(e) => onChange={(e) =>
onChange({ onChange({
...data, ...data,
price: e.target.value price: e.target.value ? Number(e.target.value) : undefined,
? Number(e.target.value)
: undefined,
}) })
} }
className="border rounded-md px-3 py-2" className="rounded-xl border border-border bg-surface-2 px-3 py-2 text-text placeholder:text-text-muted outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)]"
/> />
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">KDV dahil satış fiyatını giriniz.</p>
KDV dahil satış fiyatını giriniz.
</p>
</div> </div>
{/* BUTONLAR */} {/* Buttons */}
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
className="border px-4 py-2 rounded-md" className="rounded-xl border border-border bg-background px-4 py-2 text-text font-semibold hover:bg-surface-2"
> >
Geri Geri
</button> </button>
<button <button
type="submit" type="submit"
className="bg-primary text-white px-6 py-2 rounded-md" className="rounded-xl bg-primary px-6 py-2 font-semibold text-[color:var(--color-on-primary)] hover:bg-primary-hover"
> >
Gönder Gönder
</button> </button>
</div> </div>
</div>
</div>
</section>
</div>
</div>
</form> </form>
) )
} }

View File

@ -7,53 +7,43 @@ type Props = {
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
} }
export default function DealLinkStep({ export default function DealLinkStep({ url, loading, onChange, onSubmit }: Props) {
url,
loading,
onChange,
onSubmit,
}: Props) {
// 👉 Varsayılan: ONLINE
const [isOffline, setIsOffline] = useState(false) const [isOffline, setIsOffline] = useState(false)
// Online seçiliyse ve url boşsa, otomatik https:// koy
useEffect(() => { useEffect(() => {
if (!isOffline && url === "") { if (!isOffline && url === "") onChange("https://")
onChange("https://")
}
}, [isOffline, url, onChange]) }, [isOffline, url, onChange])
return ( return (
<form <form onSubmit={onSubmit} className="max-w-3xl mx-auto px-4">
onSubmit={onSubmit} <div className="rounded-3xl border border-border bg-surface shadow-lg p-5 sm:p-6">
className="space-y-6"
>
{/* Başlık */} {/* Başlık */}
<div> <div className="mb-5">
<h2 className="text-lg font-medium mb-1"> <h2 className="text-lg font-semibold text-text">Fırsat Türü</h2>
Fırsat Türü <p className="text-sm text-text-muted mt-1">
</h2> Satış türünü seçin. Online ise ürün linkini ekleyin.
<p className="text-sm text-text-muted">
</p> </p>
</div> </div>
{/* Online / Offline seçimi */} {/* Segmented control */}
<div className="flex gap-3"> <div className="rounded-2xl border border-border bg-background p-1 flex gap-1">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setIsOffline(false) setIsOffline(false)
onChange("https://") onChange("https://")
}} }}
className={`flex-1 border rounded-md px-4 py-3 text-sm transition className={[
${ "flex-1 rounded-xl px-4 py-3 text-sm font-semibold transition border",
!isOffline !isOffline
? "border-primary bg-primary/10 text-primary" ? "bg-primary-soft text-primary border-primary/40"
: "border-white/10 hover:border-white/30" : "bg-transparent text-text-muted border-transparent hover:bg-surface-2 hover:text-text",
} ].join(" ")}
`}
> >
Online satış (link mevcut) Online satış
<div className={!isOffline ? "text-xs font-medium text-text-muted mt-1" : "text-xs font-medium text-text-muted mt-1"}>
Link mevcut
</div>
</button> </button>
<button <button
@ -62,46 +52,65 @@ export default function DealLinkStep({
setIsOffline(true) setIsOffline(true)
onChange("") onChange("")
}} }}
className={`flex-1 border rounded-md px-4 py-3 text-sm transition className={[
${ "flex-1 rounded-xl px-4 py-3 text-sm font-semibold transition border",
isOffline isOffline
? "border-primary bg-primary/10 text-primary" ? "bg-primary-soft text-primary border-primary/40"
: "border-white/10 hover:border-white/30" : "bg-transparent text-text-muted border-transparent hover:bg-surface-2 hover:text-text",
} ].join(" ")}
`}
> >
Mağaza içi satış Mağaza içi
<div className="text-xs font-medium text-text-muted mt-1">Link yok</div>
</button> </button>
</div> </div>
{/* Link input (sadece online) */} {/* Link input */}
{!isOffline && ( {!isOffline && (
<div className="space-y-1"> <div className="mt-5">
<label className="block text-sm text-text-muted"> <label className="block text-sm font-semibold text-text">
Ürün bağlantısı Ürün bağlantısı
</label> </label>
<p className="text-xs text-text-muted mt-1">
Linki yapıştırın, sistem satıcıyı otomatik algılayabilir.
</p>
<input <input
type="url" type="url"
value={url} value={url}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder="https://www.siteadi.com/urun" placeholder="https://www.siteadi.com/urun"
required required
className="w-full rounded-md border border-white/10 bg-background px-3 py-2 text-sm className="mt-3 w-full rounded-xl border border-border bg-surface-2 px-3 py-2 text-sm text-text placeholder:text-text-muted outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)]"
focus:outline-none focus:ring-2 focus:ring-primary/40"
/> />
</div> </div>
)} )}
{/* Offline hint */}
{isOffline && (
<div className="mt-5 rounded-2xl border border-border bg-surface-2 p-4">
<div className="text-sm font-semibold text-text">
Mağaza içi satış seçildi
</div>
<div className="text-xs text-text-muted mt-1">
Bir sonraki adımda satıcı ve detayları manuel gireceksiniz.
</div>
</div>
)}
{/* Submit */} {/* Submit */}
<div className="pt-2"> <div className="mt-6">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-primary hover:bg-primary-hover text-black font-medium className="w-full rounded-xl bg-primary px-4 py-3 font-semibold text-[color:var(--color-on-primary)] transition hover:bg-primary-hover disabled:opacity-60 disabled:cursor-not-allowed"
px-4 py-2 rounded-md transition disabled:opacity-60"
> >
{loading ? "Kontrol ediliyor..." : "Devam et"} {loading ? "Kontrol ediliyor..." : "Devam et"}
</button> </button>
<div className="text-xs text-text-muted mt-3">
Devam ederek fırsat ekleme adımına geçersiniz.
</div>
</div>
</div> </div>
</form> </form>
) )

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from "react" import React, { useEffect, useState } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Heart, MessageCircle, MoreHorizontal } from "lucide-react"
import { getComments, postComment } from "../../api/deal/commentDeal" import { getComments, postComment } 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/deal/DealComment" import type { Comment } from "../../models/comment/Comment"
type DealCommentsProps = { type DealCommentsProps = {
dealId: number dealId: number
@ -47,86 +48,122 @@ export default function DealComments({ dealId, onRequireLogin }: DealCommentsPro
} }
return ( return (
<div className="bg-surface/50 rounded-xl p-6 border border-border/40"> <div className="rounded-3xl bg-surface border border-white/10 p-5 flex flex-col">
<h2 className="text-lg font-semibold mb-6">Yorumlar</h2> {/* Header */}
<div className="flex items-center justify-between gap-3">
<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">
{comments.length}
</span>
</div>
<div className="space-y-6 mb-6"> {/* List: only desktop scroll */}
<div className="mt-4 flex-1 lg:max-h-[calc(100vh-280px)] lg:overflow-y-auto pr-2">
{comments.length > 0 ? ( {comments.length > 0 ? (
comments.map((c) => ( <div className="divide-y divide-white/10">
<div {comments.map((c) => (
key={c.id} <div key={c.id} className="py-5 first:pt-0 last:pb-0">
className="flex gap-3 border-b border-border/30 pb-5 last:border-none" <div className="flex gap-3">
> <Link to={`/user/${c.user.username}`} className="shrink-0">
<Link to={`/user/${c.user.username}`}>
<img <img
src={c.user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`} src={
c.user.avatarUrl ||
`${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
}
alt={c.user.username} alt={c.user.username}
className="w-10 h-10 rounded-full object-cover" className="w-16 h-16 rounded-full object-cover border border-white/10"
/> />
</Link> </Link>
<div className="flex-1"> <div className="min-w-0 flex-1">
<div className="flex justify-between items-center"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="min-w-0 flex items-center gap-2">
<Link <Link
to={`/user/${c.user.username}`} to={`/user/${c.user.username}`}
className="font-medium text-sm hover:underline" className="text-lg font-semibold text-text hover:underline truncate"
> >
{c.user.username} {c.user.username}
</Link> </Link>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-text-muted">
{timeAgo(c.createdAt)} {timeAgo(c.createdAt)}
</span> </span>
</div> </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> </div>
<p className="mt-1 text-sm leading-relaxed">{c.text}</p> <p className="mt-2 text-base text-text leading-relaxed whitespace-pre-line">
{c.text}
<div className="flex items-center gap-5 mt-2 text-xs text-muted-foreground">
<button className="flex items-center gap-1 hover:text-primary transition">
<span>👍</span> <span>25</span>
</button>
<button className="flex items-center gap-1 hover:text-primary transition">
<span>💬</span> <span>Yanıtla</span>
</button>
<button className="hover:text-primary transition"></button>
</div>
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground text-center py-4">
Henüz yorum yok.
</p> </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 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-xs text-text-muted mt-1">
İlk yorumu sen yazabilirsin.
</div>
</div>
)} )}
</div> </div>
{/* Footer (always visible) */}
<div className="mt-5 pt-5 border-t border-white/10">
{isAuthenticated ? ( {isAuthenticated ? (
<form onSubmit={handleSubmit} className="flex items-center gap-3 pt-4 border-t border-border/40"> <form onSubmit={handleSubmit} className="flex items-center gap-3">
<input <input
type="text" type="text"
value={newComment} value={newComment}
onChange={(e) => setNewComment(e.target.value)} onChange={(e) => setNewComment(e.target.value)}
placeholder="Yorum ekle..." placeholder="Yorum ekle..."
className="flex-1 border border-border/40 rounded-full px-4 py-2 text-sm bg-background focus:ring-2 focus:ring-primary/40 focus:outline-none" 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"
disabled={loading} disabled={loading}
/> />
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="px-5 py-2 rounded-full text-sm font-medium bg-primary text-white hover:bg-primary/90 transition disabled:opacity-60" className="shrink-0 rounded-xl px-5 py-3 text-sm font-semibold bg-primary text-black hover:bg-primary-hover transition disabled:opacity-60 disabled:cursor-not-allowed"
> >
{loading ? "Gönderiliyor..." : "Gönder"} {loading ? "..." : "Gönder"}
</button> </button>
</form> </form>
) : ( ) : (
<button <button
type="button"
onClick={onRequireLogin} onClick={onRequireLogin}
className="w-full text-primary font-medium hover:underline text-sm" className="w-full rounded-xl border border-white/10 bg-background px-4 py-3 text-sm font-semibold text-primary hover:border-white/20 transition"
> >
Yorum yazmak için giriş yap veya kayıt ol Yorum yazmak için giriş yap veya kayıt ol
</button> </button>
)} )}
</div> </div>
</div>
) )
} }

View File

@ -3,12 +3,34 @@ type DealDescriptionProps = {
} }
export default function DealDescription({ description }: DealDescriptionProps) { export default function DealDescription({ description }: DealDescriptionProps) {
const hasText = Boolean(description && description.trim().length > 0)
return ( return (
<div className="bg-surface/50 rounded-lg p-4 shadow-sm"> <div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold mb-2">ıklama</h2> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line"> <h2 className="text-lg font-semibold text-text">ıklama</h2>
{!hasText ? (
<span className="text-xs text-text-muted bg-background border border-white/10 rounded-full px-3 py-1">
Yok
</span>
) : null}
</div>
{hasText ? (
<p className="text-sm text-text-muted leading-relaxed whitespace-pre-line">
{description} {description}
</p> </p>
) : (
<div className="rounded-2xl bg-background border border-white/10 p-4">
<div className="text-sm font-semibold text-text">
ıklama eklenmemiş
</div>
<div className="text-xs text-text-muted mt-1">
Bu fırsat için ek bilgi paylaşılmamış.
</div>
</div>
)}
</div> </div>
) )
} }

View File

@ -1,12 +1,42 @@
import React from "react" import React, { useMemo } from "react"
import { ExternalLink, Copy } from "lucide-react"
import { Link } from "react-router-dom"
type DealDetailsProps = { type DealDetailsProps = {
title: string title: string
price: string price: string
store: string store: string
link: string link: string
postedBy: string postedBy: string
postedAgo: string postedAgo: string // ISO veya "19 dakika" gibi bir şey gelebilir
}
function formatTimeAgo(input: string) {
if (!input) return ""
// Zaten "dakika/saat/gün" gibi geldiyse elleme
const looksHuman =
/dakika|saat|gün|hafta|ay|yıl|sn|saniye/i.test(input)
if (looksHuman) return input
const date = new Date(input)
if (Number.isNaN(date.getTime())) return input
const diffMs = Date.now() - date.getTime()
const diffSec = Math.max(0, Math.floor(diffMs / 1000))
if (diffSec < 60) return `${diffSec} sn`
const diffMin = Math.floor(diffSec / 60)
if (diffMin < 60) return `${diffMin} dk`
const diffHour = Math.floor(diffMin / 60)
if (diffHour < 24) return `${diffHour} sa`
const diffDay = Math.floor(diffHour / 24)
if (diffDay < 7) return `${diffDay} gün`
const diffWeek = Math.floor(diffDay / 7)
if (diffWeek < 4) return `${diffWeek} hf`
const diffMonth = Math.floor(diffDay / 30)
if (diffMonth < 12) return `${diffMonth} ay`
const diffYear = Math.floor(diffDay / 365)
return `${diffYear} yıl`
} }
export default function DealDetails({ export default function DealDetails({
@ -17,27 +47,90 @@ export default function DealDetails({
postedBy, postedBy,
postedAgo, postedAgo,
}: DealDetailsProps) { }: DealDetailsProps) {
const hasLink = Boolean(link && link.trim().length > 0)
const timeAgo = useMemo(() => formatTimeAgo(postedAgo), [postedAgo])
return ( return (
<div className="bg-surface/50 rounded-lg p-4 shadow-sm"> <div className="rounded-3xl bg-surface border border-border p-6">
<h1 className="text-2xl font-semibold mb-2">{title}</h1> {/* Header */}
<div className="flex items-start justify-between gap-4">
<h1 className="min-w-0 text-2xl font-semibold text-text leading-snug">
{title}
</h1>
<div className="text-xl font-bold text-primary mb-2">{price}</div> {store ? (
<span className="shrink-0 rounded-full px-3 py-1 text-xs font-semibold bg-background border border-border text-primary">
<div className="text-sm text-muted-foreground mb-4"> {store}
Mağaza: {store} </span>
) : null}
</div> </div>
{/* Meta: posted by + time */}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">
<span>
Paylaşan{" "}
{postedBy ? (
<Link
to={`/user/${postedBy}`}
className="text-text font-semibold hover:underline"
onClick={(e) => e.stopPropagation()}
>
{postedBy}
</Link>
) : (
<span className="text-text font-semibold">-</span>
)}
</span>
<span className="opacity-60"></span>
<span>{timeAgo ? `${timeAgo} önce` : "-"}</span>
</div>
{/* Price */}
<div className="mt-5 flex items-end justify-between gap-4">
<div className="flex flex-col">
<span className="text-s text-text-muted">Fiyat</span>
<span className="mt-1 text-3xl font-extrabold text-primary tracking-tight">
{price}
</span>
</div>
</div>
<div className="mt-5 h-px w-full bg-border/60" />
{/* Actions */}
<div className="mt-5 grid grid-cols-1 sm:grid-cols-2 gap-3">
<a <a
href={link} href={hasLink ? link : undefined}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-block text-sm text-blue-600 hover:underline mb-4" aria-disabled={!hasLink}
onClick={(e) => {
if (!hasLink) e.preventDefault()
}}
className={[
"inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold transition",
hasLink
? "bg-primary text-[color:var(--color-on-primary)] hover:bg-primary-hover"
: "bg-background border border-border text-text-muted cursor-not-allowed",
].join(" ")}
> >
Anlaşmayı Gör <ExternalLink className="w-4 h-4" />
Fırsata Git
</a> </a>
<div className="text-xs text-muted-foreground"> <button
Paylaşan: {postedBy} {postedAgo} önce type="button"
onClick={() => {
const value = hasLink ? link : window.location.href
try {
navigator.clipboard.writeText(value)
} catch {}
}}
className="inline-flex items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-semibold transition bg-background border border-border text-text hover:border-border/70"
>
<Copy className="w-4 h-4" />
Linki Kopyala
</button>
</div> </div>
</div> </div>
) )

View File

@ -1,17 +1,114 @@
import { useMemo, useState, useEffect } from "react"
import type { DealImage } from "../../models/deal/DealImage"
type DealImagesProps = { type DealImagesProps = {
imageUrl: string images: DealImage[]
alt?: string alt?: string
} }
export default function DealImages({ imageUrl, alt }: DealImagesProps) { export default function DealImages({ images, alt }: DealImagesProps) {
const srcs = useMemo(() => {
return (images ?? [])
.filter((x) => x?.imageUrl && x.imageUrl.trim().length > 0)
.sort((a, b) => a.order - b.order)
.slice(0, 5) // max 5
.map((x) => x.imageUrl.trim())
}, [images])
const [activeIndex, setActiveIndex] = useState(0)
const [imgError, setImgError] = useState(false)
useEffect(() => {
setActiveIndex(0)
setImgError(false)
}, [srcs.length])
if (srcs.length === 0) {
return ( return (
<div className="w-full bg-surface rounded-lg overflow-hidden shadow-sm"> <div className="w-full">
<div className="rounded-3xl bg-background border border-white/10 overflow-hidden">
<div className="relative w-full aspect-[4/3] bg-surface">
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="w-12 h-12 rounded-2xl bg-surface border border-white/10 flex items-center justify-center text-text">
🖼
</div>
<div className="text-sm font-semibold text-text">Görsel yok</div>
</div>
</div>
</div>
</div>
)
}
const safeIndex = Math.min(activeIndex, srcs.length - 1)
const activeSrc = srcs[safeIndex]
return (
<div className="w-full">
{/* Main image (no crop) */}
<div className="rounded-3xl bg-background overflow-hidden">
<div className="relative w-full aspect-[4/3] bg-surface">
{!imgError ? (
<img <img
src={imageUrl} src={activeSrc}
alt={alt ?? "deal image"} alt={alt ?? "deal image"}
className="w-full h-auto object-cover" className="absolute inset-0 w-full h-full object-contain"
onError={() => setImgError(true)}
/> />
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="w-12 h-12 rounded-2xl bg-surface border border-white/10 flex items-center justify-center text-text">
🖼
</div>
<div className="text-sm font-semibold text-text">
Görsel yüklenemedi
</div>
<div className="text-xs text-text-muted">
Daha sonra tekrar deneyin.
</div>
</div>
)}
</div>
</div>
{/* Thumbnails (crop ok for selection) */}
{srcs.length > 1 ? (
<div className="mt-4">
{/* full-width top divider */}
<div className="h-px w-full bg-white/10" />
<div className="mt-3 flex gap-3 overflow-x-auto pb-1">
{srcs.map((src, idx) => {
const active = idx === safeIndex
return (
<button
key={`${src}-${idx}`}
type="button"
onClick={() => {
setImgError(false)
setActiveIndex(idx)
}}
className={[
"shrink-0 rounded-2xl overflow-hidden border transition",
active ? "border-primary" : "border-white/10 hover:border-white/20",
].join(" ")}
aria-label={`Görsel ${idx + 1}`}
>
<div className="w-24 h-16 bg-surface">
<img
src={src}
alt={`thumb ${idx + 1}`}
className="w-full h-full object-cover"
onError={() => {}}
/>
</div>
</button>
)
})}
</div>
</div>
) : null}
</div> </div>
) )
} }

View File

@ -0,0 +1,35 @@
// src/components/ErrorBoundary.tsx
import React from "react"
type Props = {
children: React.ReactNode
}
type State = {
hasError: boolean
}
export class ErrorBoundary extends React.Component<Props, State> {
state: State = { hasError: false }
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("UI Error:", error, info)
}
render() {
if (this.state.hasError) {
return (
<div style={{ padding: 24 }}>
<h2>Bir şeyler ters gitti</h2>
<p>Sayfa yüklenirken bir hata oluştu.</p>
</div>
)
}
return this.props.children
}
}

View File

@ -1,14 +1,30 @@
import UserInfo from "./UserInfo" import UserInfo from "./UserInfo"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import SearchBar from "../Navbar/SearchBar" import SearchBar from "../Navbar/SearchBar"
import ThemeToggle from "./ThemeToggle"
import { useHideOnScroll } from "../../../hooks/useHideOnScroll"
export default function Navbar() { export default function Navbar() {
const visible = useHideOnScroll({
topThreshold: 12,
hideAfterDownPx: 12,
revealAfterUpPx: 150, // artır: daha geç gelsin
})
return ( return (
<nav className="bg-surface"> <header
className={[
"fixed top-0 left-0 right-0 z-50",
"transition-transform duration-200 will-change-transform",
visible ? "translate-y-0" : "-translate-y-full",
].join(" ")}
>
<nav className="bg-surface border-b border-border">
<div className="mx-auto flex justify-between items-center px-6 py-3"> <div className="mx-auto flex justify-between items-center px-6 py-3">
{/* Sol kısım: logo + menü */} {/* Sol: logo + menü */}
<div className="flex items-center gap-10"> <div className="flex items-center gap-10">
<div className="text-primary font-bold text-xl">DealHeat</div> <Link to="/" className="text-primary font-bold text-xl">
DealHeat
</Link>
<ul className="flex gap-6 text-text items-center"> <ul className="flex gap-6 text-text items-center">
<li> <li>
@ -20,40 +36,42 @@ export default function Navbar() {
</Link> </Link>
</li> </li>
<li> <li>
<a <Link
href="#" to="/deals"
className="hover:text-primary transition-colors font-semibold" className="hover:text-primary transition-colors font-semibold"
> >
Fırsatlar Fırsatlar
</a> </Link>
</li> </li>
<li> <li>
<a <Link
href="#" to="/contact"
className="hover:text-primary transition-colors font-semibold" className="hover:text-primary transition-colors font-semibold"
> >
İletişim İletişim
</a> </Link>
</li> </li>
</ul> </ul>
</div> </div>
{/* Orta kısım: arama kutusu */} {/* Orta: arama */}
<div className="flex-1 flex justify-center"> <div className="flex-1 flex justify-center px-6">
<SearchBar /> <SearchBar />
</div> </div>
{/* Sağ kısım: kullanıcı bilgisi + buton */} {/* Sağ: toggle + kullanıcı + buton */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ThemeToggle />
<UserInfo /> <UserInfo />
<Link <Link
to="/create-deal" to="/create-deal"
className="bg-primary text-white font-semibold px-4 py-2 rounded-md hover:bg-primary/90 transition" className="bg-primary hover:bg-primary-hover text-white font-semibold px-4 py-2 rounded-md transition"
> >
Fırsat Yolla Fırsat Yolla
</Link> </Link>
</div> </div>
</div> </div>
</nav> </nav>
</header>
) )
} }

View File

@ -14,18 +14,18 @@ export default function SearchBar() {
return ( return (
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
className="flex items-center bg-[#2A2A2A] rounded-md overflow-hidden w-full max-w-md" className="flex items-center bg-surface-2 border border-border rounded-md overflow-hidden w-full max-w-md"
> >
<input <input
type="text" type="text"
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-white placeholder-gray-400 focus:outline-none" className="flex-1 px-4 py-2 bg-transparent text-text placeholder:text-text-muted focus:outline-none"
/> />
<button <button
type="submit" type="submit"
className="bg-primary hover:bg-primary/90 text-white font-semibold px-4 py-2 transition" className="bg-primary hover:bg-primary-hover text-text font-semibold px-4 py-2 transition"
> >
Ara Ara
</button> </button>

View File

@ -0,0 +1,51 @@
import { useLayoutEffect, useState } from "react"
function getInitialIsDark() {
try {
const saved = localStorage.getItem("theme")
if (saved === "dark") return true
if (saved === "light") return false
} catch {}
return window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false
}
export default function ThemeToggle() {
const [isDark, setIsDark] = useState<boolean>(() => getInitialIsDark())
useLayoutEffect(() => {
document.documentElement.classList.toggle("dark", isDark)
document.documentElement.style.colorScheme = isDark ? "dark" : "light"
}, [isDark])
const toggle = () => {
setIsDark((prev) => {
const next = !prev
try {
localStorage.setItem("theme", next ? "dark" : "light")
} catch {}
return next
})
}
return (
<button
type="button"
onClick={toggle}
aria-label="Tema değiştir"
className="relative inline-flex h-9 w-16 items-center rounded-full border border-border bg-surface-2 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)]"
>
<span
className={`absolute left-1 inline-flex h-7 w-7 items-center justify-center rounded-full bg-surface shadow transition-transform ${
isDark ? "translate-x-7" : "translate-x-0"
}`}
>
<span className="text-sm">{isDark ? "🌙" : "☀️"}</span>
</span>
<span className="w-full px-2 text-xs font-semibold text-text-muted flex justify-between">
<span className={isDark ? "opacity-40" : "opacity-100"}></span>
<span className={isDark ? "opacity-100" : "opacity-40"}></span>
</span>
</button>
)
}

View File

@ -1,4 +1,4 @@
import { useState } from "react" 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"
@ -8,58 +8,122 @@ export default function UserInfo() {
const [menuOpen, setMenuOpen] = useState(false) const [menuOpen, setMenuOpen] = useState(false)
const [showModal, setShowModal] = useState(false) const [showModal, setShowModal] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const menuRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!menuOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false)
}
const onMouseDown = (e: MouseEvent) => {
const target = e.target
if (!(target instanceof Node)) return
if (menuRef.current && !menuRef.current.contains(target)) {
setMenuOpen(false)
}
}
document.addEventListener("keydown", onKeyDown)
document.addEventListener("mousedown", onMouseDown)
return () => {
document.removeEventListener("keydown", onKeyDown)
document.removeEventListener("mousedown", onMouseDown)
}
}, [menuOpen])
const goToAccount = () => { const goToAccount = () => {
setMenuOpen(false) setMenuOpen(false)
navigate("/account") navigate("/account")
} }
const goToProfile = () => {
if (!user?.username) return
setMenuOpen(false)
navigate(`/user/${user.username}`)
}
const handleLogout = () => { const handleLogout = () => {
logout() logout()
setMenuOpen(false) setMenuOpen(false)
} }
const avatarSrc =
user?.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
return ( return (
<div className="relative flex items-center gap-3"> <div className="relative flex items-center gap-3" ref={menuRef}>
{isAuthenticated && user ? ( {isAuthenticated && user ? (
<div className="relative"> <div className="relative">
<img <button
src={user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`} type="button"
alt={user.username}
className="w-8 h-8 rounded-full cursor-pointer border border-gray-400 hover:border-orange-500 transition"
onClick={() => setMenuOpen((prev) => !prev)} onClick={() => setMenuOpen((prev) => !prev)}
className={`rounded-full focus:outline-none focus:ring-2 focus:ring-[var(--color-primary-ring)] ${
menuOpen ? "ring-2 ring-[var(--color-primary-ring)]" : ""
}`}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<img
src={avatarSrc}
alt={user.username}
className="w-8 h-8 rounded-full border border-border hover:border-[var(--color-primary)] transition"
/> />
</button>
{menuOpen && ( {menuOpen && (
<div className="absolute right-0 mt-2 w-44 bg-surface border border-border rounded-lg shadow-md py-2 z-50"> <div
<p className="px-4 py-2 text-sm text-text font-semibold">{user.username}</p> className="absolute right-0 mt-2 w-56 bg-surface border border-border rounded-xl shadow-lg z-50 overflow-hidden"
<button role="menu"
onClick={goToAccount}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
> >
Profilim {/* Entire row clickable */}
</button>
<button <button
type="button"
onClick={goToProfile}
className="w-full text-left px-4 py-3 border-b border-border hover:bg-surface-2 transition"
role="menuitem"
>
<span className="text-sm text-text font-semibold leading-tight">
{user.username}
</span>
</button>
<div className="py-1">
<button
type="button"
onClick={goToAccount}
className="w-full text-left px-4 py-2.5 text-sm text-text hover:bg-surface-2 transition"
role="menuitem"
>
Ayarlar
</button>
<button
type="button"
onClick={handleLogout} onClick={handleLogout}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100" className="w-full text-left px-4 py-2.5 text-sm text-danger hover:bg-surface-2 transition"
role="menuitem"
> >
Çıkış yap Çıkış yap
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
) : ( ) : (
<>
<button <button
type="button"
onClick={() => setShowModal(true)} onClick={() => setShowModal(true)}
className="bg-orange-600 text-white px-4 py-2 rounded-md hover:bg-orange-700 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 && ( {showModal && <LoginModal onClose={() => setShowModal(false)} />}
<LoginModal onClose={() => setShowModal(false)} />
)}
</div> </div>
) )
} }

View File

@ -1,17 +1,62 @@
import { Link } from "react-router-dom"
import { MessageCircle, ExternalLink } from "lucide-react"
import type { Comment } from "../../models" import type { Comment } from "../../models"
import { timeAgo } from "../../utils/timeAgo"
type CommentCardProps = { type CommentCardProps = {
comment: Comment comment: Comment
} }
export default function CommentCard({ comment }: CommentCardProps) { export default function CommentCard({ comment }: CommentCardProps) {
const dealTitle = comment.deal?.title ?? "-"
const dealId = comment.deal?.id
const created = comment.createdAt
return ( return (
<div className="border rounded-lg p-3 text-sm"> <div className="rounded-2xl bg-surface border border-border p-4 hover:border-border/70 transition">
<p className="font-medium mb-1">{comment.deal.title}</p> {/* Header */}
<p>{comment.text}</p> <div className="flex items-start justify-between gap-3">
<p className="text-xs text-muted-foreground mt-1"> <div className="min-w-0">
{new Date(comment.createdAt).toLocaleString("tr-TR")} {dealId ? (
<Link
to={`/deal/${dealId}`}
className="text-base font-semibold text-text hover:text-primary transition-colors line-clamp-1"
>
{dealTitle}
</Link>
) : (
<div className="text-base font-semibold text-text line-clamp-1">
{dealTitle}
</div>
)}
<div className="mt-1 flex items-center gap-2 text-xs text-text-muted">
<span className="inline-flex items-center gap-1">
<MessageCircle className="w-3.5 h-3.5" />
Yorum
</span>
<span className="opacity-60"></span>
<span>{timeAgo(created)}</span>
</div>
</div>
{dealId ? (
<Link
to={`/deal/${dealId}`}
className="shrink-0 inline-flex items-center justify-center rounded-xl p-2 bg-background border border-border text-text-muted hover:text-primary hover:border-border/70 transition"
aria-label="Fırsata git"
>
<ExternalLink className="w-4 h-4" />
</Link>
) : null}
</div>
{/* Body */}
<div className="mt-3 rounded-xl bg-background border border-border/60 p-3">
<p className="text-sm text-text leading-relaxed whitespace-pre-line">
{comment.text}
</p> </p>
</div> </div>
</div>
) )
} }

View File

@ -1,20 +1,83 @@
// src/components/Profile/ProfileHeader.tsx // src/components/Profile/ProfileHeader.tsx
import { Heart, MessageCircle, Share2 } from "lucide-react"
type Props = { type Props = {
username: string username: string
avatarUrl?: string avatarUrl?: string
// mock: backend gelince gerçek değerleri buradan beslersin
totalLikes?: number
totalShares?: number
totalComments?: number
} }
export default function ProfileHeader({ username, avatarUrl }: Props) { export default function ProfileHeader({
username,
avatarUrl,
totalLikes ,
totalShares ,
totalComments ,
}: Props) {
return ( return (
<div className="w-full bg-surface/50 rounded-lg flex flex-col items-center justify-center py-12"> <div className="w-full rounded-3xl bg-surface border border-border overflow-hidden">
<div className="relative w-32 h-32 mb-4"> {/* Top */}
<div className="px-6 py-8 sm:px-10 sm:py-10 bg-background/40">
<div className="flex flex-col sm:flex-row items-center sm:items-end gap-5">
<div className="relative w-28 h-28 sm:w-32 sm:h-32 shrink-0">
<img <img
src={avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`} src={
avatarUrl ||
`${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
}
alt={username} alt={username}
className="w-32 h-32 rounded-full object-cover border-4 border-background" className="w-full h-full rounded-full object-cover border-4 border-surface shadow-sm"
/> />
</div> </div>
<h2 className="text-2xl font-semibold text-center">{username}</h2>
<div className="text-center sm:text-left">
<h2 className="text-2xl sm:text-3xl font-semibold text-text">
{username}
</h2>
<div className="mt-2 text-sm text-text-muted">
Profil özeti
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="px-6 py-5 sm:px-10 sm:py-6">
<div className="grid grid-cols-3 gap-3">
<div className="rounded-2xl bg-background border border-border p-4 text-center">
<div className="inline-flex items-center gap-2 text-xs text-text-muted">
<Heart className="w-4 h-4" />
Toplam Beğeni
</div>
<div className="mt-2 text-xl font-extrabold text-text">
{totalLikes}
</div>
</div>
<div className="rounded-2xl bg-background border border-border p-4 text-center">
<div className="inline-flex items-center gap-2 text-xs text-text-muted">
<Share2 className="w-4 h-4" />
Toplam Paylaşım
</div>
<div className="mt-2 text-xl font-extrabold text-text">
{totalShares}
</div>
</div>
<div className="rounded-2xl bg-background border border-border p-4 text-center">
<div className="inline-flex items-center gap-2 text-xs text-text-muted">
<MessageCircle className="w-4 h-4" />
Toplam Yorum
</div>
<div className="mt-2 text-xl font-extrabold text-text">
{totalComments}
</div>
</div>
</div>
</div>
</div> </div>
) )
} }

View File

@ -2,6 +2,7 @@ import { useState, useEffect } 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 "../../hooks/useAuth"
import { ChevronUp, ChevronDown, MessageCircle, Share2 } from "lucide-react"
type DealCardProps = { type DealCardProps = {
id: number id: number
@ -13,7 +14,8 @@ type DealCardProps = {
score: number score: number
comments: number comments: number
postedAgo: string postedAgo: string
onRequireLogin: () => void // yeni prop myVote: 1 | 0 | -1
onRequireLogin: () => void
} }
export default function DealCardMain({ export default function DealCardMain({
@ -26,22 +28,22 @@ export default function DealCardMain({
score, score,
comments, comments,
postedAgo, postedAgo,
myVote,
onRequireLogin, onRequireLogin,
}: DealCardProps) { }: DealCardProps) {
const [currentScore, setCurrentScore] = useState<number>(score) const [currentScore, setCurrentScore] = useState<number>(score)
const [currentVote, setCurrentVote] = useState<1 | 0 | -1>(myVote)
const [voting, setVoting] = useState(false) const [voting, setVoting] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const auth = useAuth() // her render'da güncel context const auth = useAuth()
useEffect(() => { useEffect(() => setCurrentScore(score), [score])
setCurrentScore(score) useEffect(() => setCurrentVote(myVote), [myVote])
}, [score])
const handleVote = async (e: React.MouseEvent, type: "UP" | "DOWN") => { const handleVote = async (e: React.MouseEvent, nextVote: 1 | 0 | -1) => {
e.stopPropagation() e.stopPropagation()
// Contextten anlık token kontrolü
const { token, isAuthenticated } = auth const { token, isAuthenticated } = auth
if (!isAuthenticated || !token) { if (!isAuthenticated || !token) {
onRequireLogin() onRequireLogin()
@ -49,78 +51,142 @@ export default function DealCardMain({
} }
setVoting(true) setVoting(true)
const prevVote = currentVote
const prevScore = currentScore
const delta = nextVote - prevVote
setCurrentVote(nextVote)
setCurrentScore(prevScore + delta)
try { try {
const data = await voteDeal( id, type) const data = await voteDeal(id, nextVote)
if (typeof data.score === "number") setCurrentScore(data.score) if (typeof data.score === "number") {
else alert(data.error || "Oy gönderilemedi") setCurrentScore(data.score)
} else {
setCurrentVote(prevVote)
setCurrentScore(prevScore)
alert(data.error || "Oy gönderilemedi")
}
} catch { } catch {
setCurrentVote(prevVote)
setCurrentScore(prevScore)
alert("Sunucu hatası") alert("Sunucu hatası")
} finally { } finally {
setVoting(false) setVoting(false)
} }
} }
const handleCardClick = () => {
navigate(`/deal/${id}`) const handleUp = (e: React.MouseEvent) => {
const next: 1 | 0 | -1 = currentVote === 1 ? 0 : 1
return handleVote(e, next)
} }
const handleDown = (e: React.MouseEvent) => {
const next: 1 | 0 | -1 = currentVote === -1 ? 0 : -1
return handleVote(e, next)
}
const handleCardClick = () => navigate(`/deal/${id}`)
return ( return (
<div <div
onClick={handleCardClick} onClick={handleCardClick}
className="flex bg-surface p-4 rounded-xl hover:shadow-md transition mb-5 cursor-pointer" 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"
> >
<div className="w-42 h-42 flex-shrink-0"> {/* Image */}
<div className="w-40 h-40 flex-shrink-0 rounded-xl bg-background border border-white/10 overflow-hidden">
<img <img
src={image} src={image}
alt={title} alt={title}
className="w-full h-full rounded-md object-scale-down" className="w-full h-full object-contain"
/> />
</div> </div>
<div className="flex flex-col justify-between flex-1 ml-4"> {/* Content */}
<div> <div className="flex flex-col justify-between flex-1 min-w-0">
<div className="flex justify-between items-center text-l text-text-muted"> <div className="flex items-start justify-between gap-3">
<span className="flex items-center gap-2"> <div className="min-w-0">
<button <div className="flex items-center gap-2 text-xs text-text-muted">
disabled={voting}
onClick={(e) => handleVote(e, "UP")}
className="text-green-600 font-bold text-lg"
>
</button>
<span>{currentScore ?? 0}°</span>
<button
disabled={voting}
onClick={(e) => handleVote(e, "DOWN")}
className="text-red-600 font-bold text-lg"
>
</button>
</span>
<span>{postedAgo}</span> <span>{postedAgo}</span>
<span className="opacity-50"></span>
<span className="truncate">{postedBy} paylaştı</span>
</div> </div>
<h2 className="text-xl font-semibold text-text mt-1 hover:text-primary"> <h2 className="text-xl font-semibold text-text mt-1 hover:text-primary line-clamp-2">
{title} {title}
</h2> </h2>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-2">
<span className="text-primary font-bold text-xl">{price}</span> <span className="text-primary font-bold text-xl">{price}</span>
<span className="text-sm text-text-muted">{store}</span> <span className="text-sm text-text-muted">{store}</span>
</div> </div>
<p className="text-sm text-text-muted mt-1 line-clamp-2">
{postedBy} tarafından paylaşıldı.
</p>
</div> </div>
<div className="flex justify-between items-center mt-3"> {/* Vote pill */}
<div className="flex gap-4 text-sm text-text-muted"> <div
<span>💬 {comments}</span>
<span>🔗 Paylaş</span>
</div>
<button
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="bg-primary text-white px-4 py-2 rounded-md hover:bg-primary/90" 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>
{/* Bottom actions */}
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-4 text-sm text-text-muted">
<span className="inline-flex items-center gap-2">
<MessageCircle className="w-4 h-4" />
{comments}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
// burada share handler yazarsın
}}
className="inline-flex items-center gap-2 hover:text-text transition"
>
<Share2 className="w-4 h-4" />
Paylaş
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation()
// CTA handler
}}
className="bg-primary text-black px-4 py-2 rounded-xl font-semibold hover:bg-primary-hover transition"
> >
Fırsatı kap Fırsatı kap
</button> </button>

View File

@ -2,18 +2,72 @@
@import "tailwindcss"; @import "tailwindcss";
/* -------------------------------------------------
THEME TOKENS (Tailwind v4 @theme değişkenleri)
Default: LIGHT
Dark: .dark class'ı ile override
-------------------------------------------------- */
@theme { @theme {
--color-background: #121212; /* LIGHT (soft graphite) */
--color-surface: #1E1E1E; --color-background: #D6DAE1; /* sayfa: açık gri ama beyaz değil */
--color-primary: #FF6B00; --color-surface: #E1E5EB; /* kart */
--color-primary-hover: #E65A00; --color-surface-2: #CBD1DA; /* input/secondary */
--color-accent: #FFD166; --color-border: #B3BBC7; /* border */
--color-text: #FFFFFF;
--color-text-muted: #B3B3B3; --color-text: #1C212B; /* koyu gri */
--color-success: #00C851; --color-text-muted: #5D6675;
--color-danger: #FF4444;
--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;
}
/* Dark overrides (class tabanlı) */
.dark {
/* DARK (seninki iyiydi; hafif rafine) */
--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;
}
/* Base */
:root {
color-scheme: light;
}
.dark {
color-scheme: dark;
} }
body { body {
@apply bg-background text-text font-sans; 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

@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from "react"
type Options = {
topThreshold?: number // en üstte her zaman açık
hideAfterDownPx?: number // aşağı inerken gizlemek için gereken px
revealAfterUpPx?: number // yukarı çıkarken göstermek için gereken px (asıl istediğin)
}
export function useHideOnScroll({
topThreshold = 12,
hideAfterDownPx = 12,
revealAfterUpPx = 80, // bunu büyüttükçe daha geç gelir (örn 60-120 iyi)
}: Options = {}) {
const [visible, setVisible] = useState(true)
const lastY = useRef(0)
const upAccum = useRef(0)
const downAccum = useRef(0)
const ticking = useRef(false)
useEffect(() => {
lastY.current = window.scrollY || 0
const onScroll = () => {
const y = window.scrollY || 0
if (ticking.current) return
ticking.current = true
requestAnimationFrame(() => {
const prev = lastY.current
const diff = y - prev // + down, - up
// top: her zaman göster
if (y <= topThreshold) {
setVisible(true)
upAccum.current = 0
downAccum.current = 0
lastY.current = y
ticking.current = false
return
}
if (diff > 0) {
// scrolling down
downAccum.current += diff
upAccum.current = 0
if (downAccum.current >= hideAfterDownPx) {
setVisible(false)
downAccum.current = 0
}
} else if (diff < 0) {
// scrolling up
upAccum.current += Math.abs(diff)
downAccum.current = 0
if (upAccum.current >= revealAfterUpPx) {
setVisible(true)
upAccum.current = 0
}
}
lastY.current = y
ticking.current = false
})
}
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [topThreshold, hideAfterDownPx, revealAfterUpPx])
return visible
}

View File

@ -1,35 +1,29 @@
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"
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode
}; }
export default function MainLayout({ children }: Props) { export default function MainLayout({ children }: Props) {
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 - tam genişlikte arka plan, ortalı içerik */}
<div className="bg-surface">
<div className="max-w-[1400px] mx-auto px-4">
<Navbar /> <Navbar />
</div>
</div>
{/* ANA İÇERİK */} {/* Content: navbar yüksekliği kadar boşluk */}
<main className="flex-1 bg-background"> <main className="flex-1 bg-background pt-14">
<div className="max-w-[1400px] mx-auto px-4 "> <div className="max-w-[1400px] mx-auto px-4">
{children} {children}
</div> </div>
</main> </main>
{/* FOOTER - tam genişlikte arka plan, ortalı içerik */} {/* Footer */}
<div className="bg-surface border-t border-zinc-800"> <div className="bg-surface border-t border-border">
<div className="max-w-[1400px] mx-auto px-4 py-4"> <div className="max-w-[1400px] mx-auto px-4 py-4">
<Footer /> <Footer />
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,31 +0,0 @@
import type { DealImage } from "./deal/DealImage.ts"
import type { DealVote } from "./deal/DealVote.ts"
import type { User } from "./User"
import type { Seller } from "./seller/Seller.ts"
export type Deal = {
id: number
title: string
description?: string
url?: string
price?: number
score: number
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED"
saleType: "ONLINE" | "OFFLINE" | "CODE"
affiliateType: "AFFILIATE" | "NON_AFFILIATE" | "USER_AFFILIATE"
sellerName: string
createdAt: string
updatedAt: string
// ilişkiler
user?: Pick<User, "id" | "username" | "avatarUrl">
company?: Pick<Seller, "id" | "name">
images?: DealImage[]
votes?: DealVote[]
comments?: Comment[]
}

View File

@ -6,7 +6,7 @@ export type DealDraft = {
url: string url: string
price?: number price?: number
imageUrl: string imageUrl: string
images?: File[] // max 5
seller: Seller seller: Seller
customCompany?: string customCompany?: string

View File

@ -0,0 +1,9 @@
import type { PublicUserSummary } from "../user/User"
import type { DealCard } from "../deal/DealCard"
export type Comment = {
id: number
text: string
createdAt: string
user:PublicUserSummary
deal:DealCard
}

View File

@ -0,0 +1,9 @@
// src/models/comment/UserComment.ts
import type { Comment } from "./Comment"
export type UserComment = Comment & {
deal: {
id: number
title: string
}
}

View File

@ -1,5 +1,5 @@
import type { PublicUserSummary } from "..//User" import type { PublicUserSummary } from "../user/User"
import type { SellerSummary } from "..//seller/Seller" import type { SellerSummary } from "..//seller/Seller"
export type DealCard = { export type DealCard = {
@ -12,6 +12,8 @@ export type DealCard = {
score: number score: number
commentsCount: number commentsCount: number
myVote:-1 | 0 | 1
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"

View File

@ -1,8 +0,0 @@
import type { PublicUserSummary } from "../User"
export type DealComment = {
id: number
text: string
createdAt: string
user:PublicUserSummary
}

View File

@ -1,8 +1,8 @@
import type { PublicUserSummary } from "..//User" 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 { DealComment } from "./DealComment" import type { Comment } from "../comment/Comment"
export type DealDetail = { export type DealDetail = {
id: number id: number
title: string title: string
@ -23,5 +23,5 @@ export type DealDetail = {
user: PublicUserSummary user: PublicUserSummary
seller: SellerSummary seller: SellerSummary
images: DealImage[] images: DealImage[]
comments: DealComment[] comments: Comment[]
} }

View File

@ -1,4 +1,4 @@
export type DealImage = { export type DealImage = {
url: string imageUrl: string
order: number order: number
} }

View File

@ -2,6 +2,6 @@ export type DealVote = {
id: number id: number
dealId: number dealId: number
userId: number userId: number
voteType: string voteType: number
createdAt: string createdAt: string
} }

View File

@ -1,5 +1,4 @@
export * from "./User" export * from "./user/User"
export * from "./Deal"
export * from "./deal/DealImage" export * from "./deal/DealImage"
export * from "./deal/DealVote" export * from "./deal/DealVote"
export * from "./deal/DealComment" export * from "./comment/Comment"

View File

@ -8,3 +8,4 @@ export type User = {
} }
export type PublicUserSummary = Pick<User, "id"|"username" | "avatarUrl" > export type PublicUserSummary = Pick<User, "id"|"username" | "avatarUrl" >
export type PublicUserDetails = Pick<User, "id"|"username" | "avatarUrl"|"createdAt" >

View File

@ -0,0 +1,10 @@
import type { PublicUserDetails } from "./User"
import type { DealCard } from "../deal/DealCard"
import type { UserComment } from "../comment/UserComment"
import type { UserStats } from "./userStats"
export type UserProfile = {
user: PublicUserDetails
deals: DealCard[]
comments: UserComment[]
stats:UserStats
}

View File

@ -0,0 +1,5 @@
export type UserStats = {
totalLikes: number
totalShares: number
totalComments: number
}

View File

@ -4,13 +4,14 @@ 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 { mapSellerFromLookupRequest } from "../adapters/requests/sellerFromLookupAdapter"
import { mapSellerFromLookupResponse } from "../adapters/responses/sellerFromLookupAdapter" 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"
import DealDetailsStep from "../components/CreateDeal/DealDetailsStep" import DealDetailsStep from "../components/CreateDeal/DealDetailsStep"
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" import type { Seller } from "../models/seller/Seller"
@ -26,6 +27,7 @@ export default function CreateDealPage() {
url: "", url: "",
price: undefined, price: undefined,
imageUrl: "", imageUrl: "",
images: [], // <-- ekle
seller: { seller: {
id: -1, id: -1,
name: "", name: "",
@ -51,29 +53,18 @@ export default function CreateDealPage() {
try { try {
// 🔥 1. URL → Seller (temporary) // 🔥 1. URL → Seller (temporary)
const tempSeller: Seller = {
id: -1,
name: "",
url: dealDraft.url,
}
// 🔥 2. Seller → Lookup Request (ADAPTER) // 🔥 2. Seller → Lookup Request (ADAPTER)
const lookupRequest = const input: SellerLookupInput = { url: dealDraft.url }
mapSellerFromLookupRequest(tempSeller)
// 🔥 3. API CALL // 🔥 3. API CALL
const lookupResponse = const seller = await lookupSellerFromLink(input)
await lookupSellerFromLink(lookupRequest)
// 🔥 4. Response → Seller (ADAPTER)
const seller =
mapSellerFromLookupResponse(lookupResponse)
setDealDraft(d => ({
...d,
seller,
}))
setDealDraft(d => {
const next = { ...d, seller }
console.log("NEXT:", next)
return next
})
setStep("details") setStep("details")
} catch (err) { } catch (err) {
console.error("Seller lookup failed:", err) console.error("Seller lookup failed:", err)
@ -97,6 +88,7 @@ export default function CreateDealPage() {
const handleFinalSubmit = async () => { const handleFinalSubmit = async () => {
try { try {
await createDeal( await createDeal(
mapDealDraftToCreateRequest(dealDraft) mapDealDraftToCreateRequest(dealDraft)
) )
@ -115,6 +107,7 @@ export default function CreateDealPage() {
url: "", url: "",
price: undefined, price: undefined,
imageUrl: "", imageUrl: "",
images: [],
seller: { seller: {
id: -1, id: -1,
name: "", name: "",
@ -127,7 +120,8 @@ export default function CreateDealPage() {
return ( return (
<MainLayout> <MainLayout>
<div className="max-w-xl mx-auto bg-surface p-6 mt-8 rounded-lg"> <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}

View File

@ -9,7 +9,6 @@ import DealDescription from "../components/DealDetails/DealDescription"
import DealComments from "../components/DealDetails/DealComments" import DealComments from "../components/DealDetails/DealComments"
import { getDealDetail } from "../api/deal/getDeal" import { getDealDetail } from "../api/deal/getDeal"
import { mapDealDetailResponseToDealDetail } from "../adapters/responses/dealDetailAdapter"
import type { DealDetail } from "../models/deal/DealDetail" import type { DealDetail } from "../models/deal/DealDetail"
type DealPageProps = { type DealPageProps = {
@ -22,32 +21,46 @@ export default function DealPage({ onRequireLogin }: DealPageProps) {
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
;(async () => {
const loadDeal = async () => {
try { try {
const apiDeal = await getDealDetail(Number(id)) const d = await getDealDetail(Number(id))
const mapped = mapDealDetailResponseToDealDetail(apiDeal) setDeal(d)
setDeal(mapped)
} catch (err) { } catch (err) {
console.error("Deal yüklenemedi:", err) console.error("Deal yüklenemedi:", err)
} }
} })()
loadDeal()
}, [id]) }, [id])
if (!deal) return <p className="p-4">Yükleniyor...</p> if (!deal) {
return (
<MainLayout>
<div className="max-w-[1400px] mx-auto px-4 py-10">
<div className="rounded-3xl bg-surface border border-white/10 p-6">
<p className="text-text-muted">Yükleniyor...</p>
</div>
</div>
</MainLayout>
)
}
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: görseller */} {/* üst ana grid */}
<div className="lg:col-span-1 flex justify-center items-start"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<DealImages imageUrl={deal.images[0].url} /> {/* SOL: Görseller */}
<div className="lg:col-span-7">
<div className="rounded-3xl bg-surface border border-white/10 p-4 sm:p-5">
<DealImages images={deal.images} />
</div>
</div> </div>
{/* Sağ: temel bilgiler */} {/* SAĞ: Detay kartı (sticky) */}
<div className="lg:col-span-3 flex flex-col gap-6"> <div className="lg:col-span-5">
<div className="lg:sticky lg:top-24">
<DealDetails <DealDetails
title={deal.title} title={deal.title}
price={deal.price?.toString() ?? "-"} price={deal.price?.toString() ?? "-"}
@ -58,18 +71,21 @@ export default function DealPage({ onRequireLogin }: DealPageProps) {
/> />
</div> </div>
{/* Alt: açıklama + yorumlar */} {/* küçük yan bilgi alanı */}
<div className="lg:col-span-4 flex flex-col gap-6">
<section>
<DealDescription description={deal.description} /> </div>
</section>
{/* ALT: Açıklama + Yorumlar */}
{/* ALT: açıklama + yorumlar (tam genişlik) */}
<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">
<DealDescription description={deal.description} />
</div>
<DealComments dealId={deal.id} onRequireLogin={onRequireLogin} />
</div>
<section>
<DealComments
dealId={deal.id}
onRequireLogin={onRequireLogin}
/>
</section>
</div> </div>
</div> </div>
</MainLayout> </MainLayout>

View File

@ -25,15 +25,15 @@ export default function HomePage({ onRequireLogin }: HomeProps) {
setLoading(true) setLoading(true)
try { try {
const apiDeals = await getDeals(page) const deals = await getDeals(page)
if (apiDeals.length === 0) { if (deals.length === 0) {
setHasMore(false) setHasMore(false)
} else { } else {
const mappedDeals = apiDeals.map(mapDealCardResponseToDeal)
setDeals((prev) => { setDeals((prev) => {
const existingIds = new Set(prev.map((d) => d.id)) const existingIds = new Set(prev.map((d) => d.id))
const filtered = mappedDeals.filter( const filtered = deals.filter(
(d) => !existingIds.has(d.id) (d) => !existingIds.has(d.id)
) )
return [...prev, ...filtered] return [...prev, ...filtered]
@ -84,8 +84,10 @@ export default function HomePage({ onRequireLogin }: HomeProps) {
score={deal.score} score={deal.score}
comments={deal.commentsCount} comments={deal.commentsCount}
postedAgo={timeAgo(deal.createdAt)} postedAgo={timeAgo(deal.createdAt)}
myVote={deal.myVote}
onRequireLogin={onRequireLogin} onRequireLogin={onRequireLogin}
/> />
))} ))}
{loading && ( {loading && (

View File

@ -3,80 +3,89 @@ import { useParams } 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 CommentCard from "../components/Profile/CommentCard" import CommentCard from "../components/Profile/CommentCard"
import ProfileHeader from "../components/Profile/ProfileHeader"
import { fetchUserProfile } from "../services/userService" import { fetchUserProfile } from "../services/userService"
import type { Deal, Comment } from "../models" import { timeAgo } from "../utils/timeAgo"
import type { PublicUserSummary } from "../models/User"
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 [user, setUser] = useState<PublicUserSummary | null>(null) const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
const [deals, setDeals] = useState<Deal[]>([])
const [comments, setComments] = useState<Comment[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!userName) return if (!userName) return
const loadUser = async () => { const loadUser = async () => {
setLoading(true) setLoading(true)
setError(null)
try { try {
const { user, deals, comments } = await fetchUserProfile(userName) const profile = await fetchUserProfile(userName)
setUser(user) setUserProfile(profile)
setDeals(deals)
setComments(comments)
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
setError(err.message) setError(err.message || "Sunucu hatası")
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
loadUser() loadUser()
}, [userName]) }, [userName])
if (loading) return <p className="p-4 text-center">Yükleniyor...</p> if (loading) return <p className="p-6 text-center text-text-muted">Yükleniyor...</p>
if (error) return <p className="p-4 text-center text-red-600">{error}</p> if (error) return <p className="p-6 text-center text-danger">{error}</p>
if (!user) return <p className="p-4 text-center">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 bilgisi */} {/* ÜST: profil header */}
<div className="bg-surface/50 rounded-lg p-6 flex flex-col items-center text-center shadow-sm"> <ProfileHeader
<img username={userProfile.user.username}
src={user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`} avatarUrl={userProfile.user.avatarUrl ?? undefined}
alt="avatar" totalLikes={userProfile.stats.totalLikes}
className="w-24 h-24 rounded-full mb-3 border" totalShares={userProfile.stats.totalShares}
totalComments={userProfile.stats.totalComments}
/> />
<h1 className="text-xl font-semibold">{user.username}</h1>
<p className="text-xs text-muted-foreground mt-1">
Katılma: {new Date(user.createdAt).toLocaleDateString("tr-TR")}
</p>
</div>
{/* MENÜ */} {/* TAB BAR */}
<div className="border-b border-border flex justify-center gap-8 text-sm font-medium"> <div className="rounded-2xl bg-surface border border-border p-2 flex items-center justify-center gap-2">
<button <button
type="button"
onClick={() => setActiveTab("deals")} onClick={() => setActiveTab("deals")}
className={`py-2 ${ className={[
"px-4 py-2 rounded-xl text-sm font-semibold transition",
activeTab === "deals" activeTab === "deals"
? "text-primary border-b-2 border-primary" ? "bg-background border border-border text-primary"
: "text-muted-foreground" : "text-text-muted hover:text-text",
}`} ].join(" ")}
> >
Paylaşımlar Paylaşımlar
<span className="ml-2 text-xs text-text-muted">
{userProfile.deals?.length ?? 0}
</span>
</button> </button>
<button <button
type="button"
onClick={() => setActiveTab("comments")} onClick={() => setActiveTab("comments")}
className={`py-2 ${ className={[
"px-4 py-2 rounded-xl text-sm font-semibold transition",
activeTab === "comments" activeTab === "comments"
? "text-primary border-b-2 border-primary" ? "bg-background border border-border text-primary"
: "text-muted-foreground" : "text-text-muted hover:text-text",
}`} ].join(" ")}
> >
Yorumlar Yorumlar
<span className="ml-2 text-xs text-text-muted">
{userProfile.comments?.length ?? 0}
</span>
</button> </button>
</div> </div>
@ -84,42 +93,46 @@ export default function ProfilePage() {
<div className="min-h-[300px]"> <div className="min-h-[300px]">
{activeTab === "deals" ? ( {activeTab === "deals" ? (
<div className="space-y-4"> <div className="space-y-4">
{deals.length > 0 ? ( {userProfile.deals?.length > 0 ? (
deals.map((deal) => { userProfile.deals.map((deal) => (
const firstImage = deal.images?.[0]?.imageUrl || "/placeholder.png"
const postedAgo = new Date(deal.createdAt).toLocaleDateString("tr-TR")
return (
<DealCardMain <DealCardMain
key={deal.id} key={deal.id}
id={deal.id} id={deal.id}
image={firstImage} image={deal.imageUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-deal.png`}
title={deal.title} title={deal.title}
price={`${deal.price ?? 0}`} price={deal.price != null ? `${deal.price}` : ""}
store={ "Bilinmiyor"}
postedBy={user.username} store={deal.seller?.name ?? ""}
postedBy={deal.user?.username ?? "unknown"}
score={deal.score} score={deal.score}
comments={0} comments={deal.commentsCount}
postedAgo={postedAgo} postedAgo={timeAgo(deal.createdAt)}
myVote={deal.myVote}
onRequireLogin={() => {}} onRequireLogin={() => {}}
/> />
) ))
})
) : ( ) : (
<p className="text-center text-muted-foreground py-8"> <div className="rounded-2xl bg-surface border border-border p-6 text-center">
Henüz paylaşım yok. <div className="text-sm font-semibold text-text">Henüz paylaşım yok</div>
</p> <div className="text-xs text-text-muted mt-1">
Bu kullanıcı daha fırsat paylaşmamış.
</div>
</div>
)} )}
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{comments.length > 0 ? ( {userProfile.comments?.length > 0 ? (
comments.map((c) => <CommentCard key={c.id} comment={c} />) userProfile.comments.map((c) => (
<CommentCard key={c.id} comment={c} />
))
) : ( ) : (
<p className="text-center text-muted-foreground py-8"> <div className="rounded-2xl bg-surface border border-border p-6 text-center">
Henüz yorum yok. <div className="text-sm font-semibold text-text">Henüz yorum yok</div>
</p> <div className="text-xs text-text-muted mt-1">
Bu kullanıcı daha yorum yapmamış.
</div>
</div>
)} )}
</div> </div>
)} )}

View File

@ -70,13 +70,13 @@ export default function SearchPage({ onRequireLogin }: { onRequireLogin: () => v
<DealCardMain <DealCardMain
key={deal.id} key={deal.id}
id={deal.id} id={deal.id}
image={deal.images[0]?.imageUrl || "/placeholder.png"} image={deal.imageUrl || "/placeholder.png"}
title={deal.title} title={deal.title}
price={`${deal.price}`} price={deal.price ? `${deal.price}` : ""}
store={deal.url ? new URL(deal.url).hostname : "Bilinmiyor"} store={deal.seller.name}
postedBy={deal.user?.username || "unknown"} postedBy={deal.user?.username ?? "unknown"}
score={deal.score} score={deal.score}
comments={0} comments={deal.commentsCount}
postedAgo={timeAgo(deal.createdAt)} postedAgo={timeAgo(deal.createdAt)}
onRequireLogin={onRequireLogin} onRequireLogin={onRequireLogin}
/> />

View File

@ -1,41 +1,7 @@
import { getUser } from "../api/user/getUser" import { getUser } from "../api/user/getUser"
import type { PublicUserSummary, Deal, Comment } from "../models" import type { UserProfile } from "../models/user/UserProfile"
export type UserProfile = {
user: PublicUserSummary
deals: Deal[]
comments: Comment[]
}
export async function fetchUserProfile(userName: string): Promise<UserProfile> { export async function fetchUserProfile(userName: string): Promise<UserProfile> {
const data = await getUser(userName) const data = await getUser(userName)
return data
const user: PublicUserSummary = {
username: data.user.username,
avatarUrl: data.user.avatarUrl,
createdAt: data.user.createdAt,
}
const deals: Deal[] = data.deals.map((d: any) => ({
id: d.id,
title: d.title,
price: d.price,
store: d.store,
score: d.score,
createdAt: d.createdAt,
images:
d.images?.map((img: any) => ({
imageUrl: img.imageUrl,
order: img.order,
})) || [],
}))
const comments: Comment[] = data.comments.map((c: any) => ({
id: c.id,
text: c.text,
createdAt: c.createdAt,
deal: { title: c.deal?.title || "Bilinmeyen paylaşım" },
}))
return { user, deals, comments }
} }