created image array
ui overhaul
This commit is contained in:
parent
c06b4b8211
commit
a48d32fdec
|
|
@ -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
10
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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
4
src/api/auth/types.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export type LoginInput = {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
@ -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ı"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,6 @@ export type SellerFromLookupResponse= {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type SellerFromLookupRequest={
|
export type SellerLookupInput={
|
||||||
url:string|null
|
url:string|null
|
||||||
}
|
}
|
||||||
|
|
@ -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ı"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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">Açıklama</label>
|
||||||
Açı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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">Açı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">Açı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">
|
||||||
|
Açı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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
src/components/ErrorBoundary.tsx
Normal file
35
src/components/ErrorBoundary.tsx
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
51
src/components/Layout/Navbar/ThemeToggle.tsx
Normal file
51
src/components/Layout/Navbar/ThemeToggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
// Context’ten 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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
73
src/hooks/useHideOnScroll.ts
Normal file
73
src/hooks/useHideOnScroll.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
9
src/models/comment/Comment.ts
Normal file
9
src/models/comment/Comment.ts
Normal 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
|
||||||
|
}
|
||||||
9
src/models/comment/UserComment.ts
Normal file
9
src/models/comment/UserComment.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
// src/models/comment/UserComment.ts
|
||||||
|
import type { Comment } from "./Comment"
|
||||||
|
|
||||||
|
export type UserComment = Comment & {
|
||||||
|
deal: {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
import type { PublicUserSummary } from "../User"
|
|
||||||
|
|
||||||
export type DealComment = {
|
|
||||||
id: number
|
|
||||||
text: string
|
|
||||||
createdAt: string
|
|
||||||
user:PublicUserSummary
|
|
||||||
}
|
|
||||||
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export type DealImage = {
|
export type DealImage = {
|
||||||
url: string
|
imageUrl: string
|
||||||
order: number
|
order: number
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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" >
|
||||||
10
src/models/user/UserProfile.ts
Normal file
10
src/models/user/UserProfile.ts
Normal 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
|
||||||
|
}
|
||||||
5
src/models/user/userStats.ts
Normal file
5
src/models/user/userStats.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type UserStats = {
|
||||||
|
totalLikes: number
|
||||||
|
totalShares: number
|
||||||
|
totalComments: number
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user