created image array
ui overhaul
This commit is contained in:
parent
c06b4b8211
commit
a48d32fdec
|
|
@ -1,4 +1,5 @@
|
|||
<!doctype html>
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"axios": "^1.13.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.4"
|
||||
|
|
@ -3703,6 +3704,15 @@
|
|||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"axios": "^1.13.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.4"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from "react"
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom"
|
||||
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary"
|
||||
import HomePage from "./pages/HomePage"
|
||||
import DealPage from "./pages/DealDetailsPage"
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ export default function App() {
|
|||
const [showLoginModal, setShowLoginModal] = useState(false)
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
{showLoginModal && (
|
||||
<LoginModal
|
||||
|
|
@ -42,5 +43,6 @@ export default function App() {
|
|||
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
|
||||
// adapters/requests/dealCreateAdapter.ts
|
||||
import type { DealDraft } from "../../models/DealDraft"
|
||||
import type { CreateDealRequest } from "../../api/deal/types"
|
||||
|
||||
export function mapDealDraftToCreateRequest(
|
||||
draft: DealDraft
|
||||
): CreateDealRequest {
|
||||
return {
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
price: draft.price,
|
||||
imageUrl: draft.imageUrl,
|
||||
export function mapDealDraftToCreateRequest(draft: DealDraft): FormData {
|
||||
const fd = new FormData()
|
||||
|
||||
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,
|
||||
affiliateType: api.affiliateType,
|
||||
|
||||
myVote:api.myVote,
|
||||
|
||||
createdAt: api.createdAt,
|
||||
updatedAt:api.updatedAt,
|
||||
user: {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
// src/api/auth/login.ts
|
||||
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 })
|
||||
return data // { token, user }
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message || "Giriş başarısız"
|
||||
console.error("Login hatası:", message)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
|
||||
export async function login(input: LoginInput) {
|
||||
const { data } = await instance.post("/auth/login", input)
|
||||
return data
|
||||
}
|
||||
|
|
|
|||
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
|
||||
import instance from "../axiosInstance"
|
||||
|
||||
export async function getComments(dealId: number) {
|
||||
import type { Comment } from "../../models"
|
||||
|
||||
export async function getComments(dealId: number): Promise<Comment[]> {
|
||||
try {
|
||||
const { data } = await instance.get(`/comments/${dealId}`)
|
||||
const { data } = await instance.get<Comment[]>(`/comments/${dealId}`)
|
||||
return data
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.error || "Yorumlar alınamadı"
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
// src/api/deal/dealApi.ts
|
||||
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(
|
||||
page = 1
|
||||
): Promise<DealCardResponse[]> {
|
||||
const { data } = await instance.get(`/deals?page=${page}`)
|
||||
export async function getDeals(page = 1): Promise<DealCard[]> {
|
||||
const { data } = await instance.get<{ results: DealCard[] }>(
|
||||
`/deals?page=${page}`
|
||||
)
|
||||
console.log(data.results)
|
||||
return data.results
|
||||
}
|
||||
|
||||
export async function getDealDetail(
|
||||
id: number
|
||||
): Promise<DealDetailResponse> {
|
||||
const { data } = await instance.get(`/deals/${id}`)
|
||||
export async function getDealDetail(id: number): Promise<DealDetail> {
|
||||
const { data } = await instance.get<DealDetail>(`/deals/${id}`)
|
||||
return data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,18 @@
|
|||
// src/api/deal/createDeal.ts
|
||||
import instance from "../axiosInstance"
|
||||
|
||||
type DealData = {
|
||||
title: string
|
||||
description?: string
|
||||
url?: string
|
||||
imageUrl?: string
|
||||
customCompany?: string
|
||||
price?: number
|
||||
}
|
||||
|
||||
export async function createDeal(dealData: DealData) {
|
||||
export async function createDeal(formData: FormData) {
|
||||
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
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.error || "Fırsat eklenemedi"
|
||||
const message =
|
||||
error.response?.data?.error || "Fırsat eklenemedi"
|
||||
console.error("Fırsat oluşturma hatası:", message)
|
||||
throw new Error(message)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import instance from "../axiosInstance"
|
||||
import type { DealCard } from "../../models/deal/DealCard"
|
||||
|
||||
export async function searchDeals(query: string, page = 1) {
|
||||
try {
|
||||
const { data } = await instance.get(`/deals/search`, {
|
||||
|
||||
export async function searchDeals(
|
||||
query: string,
|
||||
page = 1
|
||||
): Promise<DealCard[]> {
|
||||
const { data } = await instance.get<{ results: DealCard[] }>(
|
||||
`/deals/`,
|
||||
{
|
||||
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
|
||||
price: number | null // fiyat yoksa bilinçli null
|
||||
|
||||
myVote: -1 | 0 | 1
|
||||
score: number
|
||||
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED"
|
||||
saleType: "ONLINE" | "OFFLINE" | "CODE"
|
||||
|
|
@ -87,7 +88,7 @@ export type CreateDealRequest = {
|
|||
description?: string
|
||||
price?: number
|
||||
imageUrl: string
|
||||
|
||||
images?:File[]
|
||||
// online deal
|
||||
url?: string
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// src/api/deal/voteDeal.ts
|
||||
import instance from "../axiosInstance"
|
||||
|
||||
export async function voteDeal(dealId: number, type: "UP" | "DOWN") {
|
||||
export async function voteDeal(dealId: number, type: 1 | 0 | -1) {
|
||||
try {
|
||||
const { data } = await instance.post("/deal-votes", {
|
||||
const { data } = await instance.post("/vote", {
|
||||
dealId,
|
||||
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"
|
||||
|
||||
|
||||
|
||||
export async function lookupSellerFromLink(seller:SellerFromLookupRequest) :Promise<SellerFromLookupResponse>{
|
||||
const { data } = await instance.post(
|
||||
"/seller/from-link",
|
||||
{ seller }
|
||||
)
|
||||
|
||||
export async function lookupSellerFromLink(input: SellerLookupInput): Promise<Seller> {
|
||||
const { data } = await instance.post<Seller>("/seller/from-link", input)
|
||||
return data
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,6 @@ export type SellerFromLookupResponse= {
|
|||
}
|
||||
|
||||
|
||||
export type SellerFromLookupRequest={
|
||||
export type SellerLookupInput={
|
||||
url:string|null
|
||||
}
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
// src/api/user/getUser.ts
|
||||
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 {
|
||||
const res = await instance.get(`/user/${userName}`)
|
||||
return res.data // { user, deals, comments }
|
||||
const { data } = await instance.get<UserProfile>(`/user/${userName}`)
|
||||
return data
|
||||
} catch (err: any) {
|
||||
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 { login as loginApi } from "../../api/auth/login"
|
||||
import { register as registerApi } from "../../api/auth/register"
|
||||
|
||||
import type { LoginInput } from "../../api/auth/types"
|
||||
type LoginModalProps = {
|
||||
onClose: () => void
|
||||
}
|
||||
|
|
@ -20,7 +20,12 @@ export default function LoginModal({ onClose }: LoginModalProps) {
|
|||
const data = await registerApi(username, email, password)
|
||||
login(data.user, data.token)
|
||||
} else {
|
||||
const data = await loginApi(email, password)
|
||||
|
||||
const input: LoginInput = {
|
||||
email,
|
||||
password,
|
||||
}
|
||||
const data = await loginApi(input)
|
||||
login(data.user, data.token)
|
||||
}
|
||||
onClose()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useMemo, useRef } from "react"
|
||||
import type { DealDraft } from "../../models/DealDraft"
|
||||
|
||||
type Props = {
|
||||
|
|
@ -7,13 +8,47 @@ type Props = {
|
|||
onSubmit: () => void
|
||||
}
|
||||
|
||||
export default function DealDetailsStep({
|
||||
data,
|
||||
onChange,
|
||||
onBack,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const hasDetectedCompany = Boolean(data.sellerId)
|
||||
const MAX_IMAGES = 5
|
||||
|
||||
export default function DealDetailsStep({ data, onChange, onBack, onSubmit }: Props) {
|
||||
const hasDetectedCompany = data?.seller?.id !== -1
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
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 (
|
||||
<form
|
||||
|
|
@ -21,154 +56,227 @@ export default function DealDetailsStep({
|
|||
e.preventDefault()
|
||||
onSubmit()
|
||||
}}
|
||||
className="max-w-2xl mx-auto flex flex-col gap-6"
|
||||
className="max-w-5xl mx-auto px-4"
|
||||
>
|
||||
{/* BAŞLIK */}
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">
|
||||
Fırsat Detayları
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Fırsata ait temel bilgileri aşağıdaki alanlara giriniz.
|
||||
</p>
|
||||
</div>
|
||||
{/* 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>
|
||||
<h2 className="text-base font-semibold text-text">Fotoğraflar</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
En fazla {MAX_IMAGES} fotoğraf ekleyebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* BAŞLIK */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium">
|
||||
Fırsat başlığı
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Örn: %40 indirimli spor ayakkabı"
|
||||
value={data.title}
|
||||
onChange={(e) =>
|
||||
onChange({ ...data, title: e.target.value })
|
||||
}
|
||||
className="border rounded-md px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Kullanıcının ilk göreceği kısa ve net başlık.
|
||||
</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>
|
||||
|
||||
{/* AÇIKLAMA */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium">
|
||||
Açıklama
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="İndirim koşulları, geçerlilik süresi veya ek bilgiler..."
|
||||
value={data.description ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...data, description: e.target.value })
|
||||
}
|
||||
className="border rounded-md px-3 py-2 h-28 resize-none"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
İsteğe bağlıdır. Fırsat hakkında ek bilgi verebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={(e) => onPickFiles(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SATICI */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium">
|
||||
Satıcı bilgisi
|
||||
</label>
|
||||
{/* 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>
|
||||
|
||||
{hasDetectedCompany ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={data.sellerName ?? ""}
|
||||
disabled
|
||||
className="border rounded-md px-3 py-2 bg-gray-100 text-gray-700 cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Satıcı bilgisi ürün bağlantısından otomatik olarak algılanmıştır.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Satıcı adını giriniz"
|
||||
value={data.customCompany ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...data,
|
||||
customCompany: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border rounded-md px-3 py-2"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Satıcı bilgisi otomatik olarak algılanamazsa manuel olarak girilebilir.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{/* 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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Title */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-text">Fırsat başlığı</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Örn: %40 indirimli spor ayakkabı"
|
||||
value={data.title}
|
||||
onChange={(e) => 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)]"
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Kullanıcının ilk göreceği kısa ve net başlık.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-text">Açıklama</label>
|
||||
<textarea
|
||||
placeholder="İndirim koşulları, geçerlilik süresi veya ek bilgiler..."
|
||||
value={data.description ?? ""}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
İsteğe bağlıdır. Fırsat hakkında ek bilgi verebilirsiniz.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Seller */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-text">Satıcı bilgisi</label>
|
||||
|
||||
{hasDetectedCompany ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={data.seller.name ?? ""}
|
||||
disabled
|
||||
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">
|
||||
Satıcı bilgisi ürün bağlantısından otomatik olarak algılanmıştır.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Satıcı adını giriniz"
|
||||
value={data.customCompany ?? ""}
|
||||
onChange={(e) => onChange({ ...data, customCompany: 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)]"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
Satıcı bilgisi otomatik algılanamazsa manuel girilebilir.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-text">Fiyat</label>
|
||||
<input
|
||||
inputMode="numeric"
|
||||
placeholder="Örn: 999"
|
||||
value={data.price ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...data,
|
||||
price: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
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">KDV dahil satış fiyatını giriniz.</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-xl border border-border bg-background px-4 py-2 text-text font-semibold hover:bg-surface-2"
|
||||
>
|
||||
Geri
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-xl bg-primary px-6 py-2 font-semibold text-[color:var(--color-on-primary)] hover:bg-primary-hover"
|
||||
>
|
||||
Gönder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* GÖRSEL */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium">
|
||||
Görsel bağlantısı
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
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"
|
||||
value={data.price ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...data,
|
||||
price: e.target.value
|
||||
? Number(e.target.value)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
className="border rounded-md px-3 py-2"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">
|
||||
KDV dahil satış fiyatını giriniz.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* BUTONLAR */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="border px-4 py-2 rounded-md"
|
||||
>
|
||||
Geri
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-primary text-white px-6 py-2 rounded-md"
|
||||
>
|
||||
Gönder
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,101 +7,110 @@ type Props = {
|
|||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
}
|
||||
|
||||
export default function DealLinkStep({
|
||||
url,
|
||||
loading,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
// 👉 Varsayılan: ONLINE
|
||||
export default function DealLinkStep({ url, loading, onChange, onSubmit }: Props) {
|
||||
const [isOffline, setIsOffline] = useState(false)
|
||||
|
||||
// Online seçiliyse ve url boşsa, otomatik https:// koy
|
||||
useEffect(() => {
|
||||
if (!isOffline && url === "") {
|
||||
onChange("https://")
|
||||
}
|
||||
if (!isOffline && url === "") onChange("https://")
|
||||
}, [isOffline, url, onChange])
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Başlık */}
|
||||
<div>
|
||||
<h2 className="text-lg font-medium mb-1">
|
||||
Fırsat Türü
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Online / Offline seçimi */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOffline(false)
|
||||
onChange("https://")
|
||||
}}
|
||||
className={`flex-1 border rounded-md px-4 py-3 text-sm transition
|
||||
${
|
||||
!isOffline
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-white/10 hover:border-white/30"
|
||||
}
|
||||
`}
|
||||
>
|
||||
Online satış (link mevcut)
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOffline(true)
|
||||
onChange("")
|
||||
}}
|
||||
className={`flex-1 border rounded-md px-4 py-3 text-sm transition
|
||||
${
|
||||
isOffline
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-white/10 hover:border-white/30"
|
||||
}
|
||||
`}
|
||||
>
|
||||
Mağaza içi satış
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link input (sadece online) */}
|
||||
{!isOffline && (
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm text-text-muted">
|
||||
Ürün bağlantısı
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://www.siteadi.com/urun"
|
||||
required
|
||||
className="w-full rounded-md border border-white/10 bg-background px-3 py-2 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
/>
|
||||
<form onSubmit={onSubmit} className="max-w-3xl mx-auto px-4">
|
||||
<div className="rounded-3xl border border-border bg-surface shadow-lg p-5 sm:p-6">
|
||||
{/* Başlık */}
|
||||
<div className="mb-5">
|
||||
<h2 className="text-lg font-semibold text-text">Fırsat Türü</h2>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Satış türünü seçin. Online ise ürün linkini ekleyin.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-primary hover:bg-primary-hover text-black font-medium
|
||||
px-4 py-2 rounded-md transition disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Kontrol ediliyor..." : "Devam et"}
|
||||
</button>
|
||||
{/* Segmented control */}
|
||||
<div className="rounded-2xl border border-border bg-background p-1 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOffline(false)
|
||||
onChange("https://")
|
||||
}}
|
||||
className={[
|
||||
"flex-1 rounded-xl px-4 py-3 text-sm font-semibold transition border",
|
||||
!isOffline
|
||||
? "bg-primary-soft text-primary border-primary/40"
|
||||
: "bg-transparent text-text-muted border-transparent hover:bg-surface-2 hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsOffline(true)
|
||||
onChange("")
|
||||
}}
|
||||
className={[
|
||||
"flex-1 rounded-xl px-4 py-3 text-sm font-semibold transition border",
|
||||
isOffline
|
||||
? "bg-primary-soft text-primary border-primary/40"
|
||||
: "bg-transparent text-text-muted border-transparent hover:bg-surface-2 hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
Mağaza içi
|
||||
<div className="text-xs font-medium text-text-muted mt-1">Link yok</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link input */}
|
||||
{!isOffline && (
|
||||
<div className="mt-5">
|
||||
<label className="block text-sm font-semibold text-text">
|
||||
Ürün bağlantısı
|
||||
</label>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Linki yapıştırın, sistem satıcıyı otomatik algılayabilir.
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="https://www.siteadi.com/urun"
|
||||
required
|
||||
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)]"
|
||||
/>
|
||||
</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 */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
{loading ? "Kontrol ediliyor..." : "Devam et"}
|
||||
</button>
|
||||
|
||||
<div className="text-xs text-text-muted mt-3">
|
||||
Devam ederek fırsat ekleme adımına geçersiniz.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useEffect, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Heart, MessageCircle, MoreHorizontal } from "lucide-react"
|
||||
import { getComments, postComment } from "../../api/deal/commentDeal"
|
||||
import { useAuth } from "../../context/AuthContext"
|
||||
import { timeAgo } from "../../utils/timeAgo"
|
||||
import type { Comment } from "../../models/deal/DealComment"
|
||||
import type { Comment } from "../../models/comment/Comment"
|
||||
|
||||
type DealCommentsProps = {
|
||||
dealId: number
|
||||
|
|
@ -47,86 +48,122 @@ export default function DealComments({ dealId, onRequireLogin }: DealCommentsPro
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface/50 rounded-xl p-6 border border-border/40">
|
||||
<h2 className="text-lg font-semibold mb-6">Yorumlar</h2>
|
||||
<div className="rounded-3xl bg-surface border border-white/10 p-5 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<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.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex gap-3 border-b border-border/30 pb-5 last:border-none"
|
||||
>
|
||||
<Link to={`/user/${c.user.username}`}>
|
||||
<img
|
||||
src={c.user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||
alt={c.user.username}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
</Link>
|
||||
<div className="divide-y divide-white/10">
|
||||
{comments.map((c) => (
|
||||
<div key={c.id} className="py-5 first:pt-0 last:pb-0">
|
||||
<div className="flex gap-3">
|
||||
<Link to={`/user/${c.user.username}`} className="shrink-0">
|
||||
<img
|
||||
src={
|
||||
c.user.avatarUrl ||
|
||||
`${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
|
||||
}
|
||||
alt={c.user.username}
|
||||
className="w-16 h-16 rounded-full object-cover border border-white/10"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/user/${c.user.username}`}
|
||||
className="font-medium text-sm hover:underline"
|
||||
>
|
||||
{c.user.username}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{timeAgo(c.createdAt)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex items-center gap-2">
|
||||
<Link
|
||||
to={`/user/${c.user.username}`}
|
||||
className="text-lg font-semibold text-text hover:underline truncate"
|
||||
>
|
||||
{c.user.username}
|
||||
</Link>
|
||||
<span className="text-xs text-text-muted">
|
||||
{timeAgo(c.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg p-2 bg-background border border-white/10 text-text-muted hover:text-text hover:border-white/20 transition"
|
||||
aria-label="Yorum seçenekleri"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-base text-text leading-relaxed whitespace-pre-line">
|
||||
{c.text}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold bg-background border border-white/10 text-text-muted hover:text-primary hover:border-white/20 transition"
|
||||
>
|
||||
<Heart className="w-4 h-4" />
|
||||
<span>25</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-semibold bg-background border border-white/10 text-text-muted hover:text-primary hover:border-white/20 transition"
|
||||
>
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span>Yanıtla</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-1 text-sm leading-relaxed">{c.text}</p>
|
||||
|
||||
<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>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
Henüz yorum yok.
|
||||
</p>
|
||||
<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>
|
||||
|
||||
{isAuthenticated ? (
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-3 pt-4 border-t border-border/40">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
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"
|
||||
disabled={loading}
|
||||
/>
|
||||
{/* Footer (always visible) */}
|
||||
<div className="mt-5 pt-5 border-t border-white/10">
|
||||
{isAuthenticated ? (
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Yorum ekle..."
|
||||
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}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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önder"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
type="button"
|
||||
onClick={onRequireLogin}
|
||||
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"
|
||||
>
|
||||
{loading ? "Gönderiliyor..." : "Gönder"}
|
||||
Yorum yazmak için giriş yap veya kayıt ol
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={onRequireLogin}
|
||||
className="w-full text-primary font-medium hover:underline text-sm"
|
||||
>
|
||||
Yorum yazmak için giriş yap veya kayıt ol
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,34 @@ type DealDescriptionProps = {
|
|||
}
|
||||
|
||||
export default function DealDescription({ description }: DealDescriptionProps) {
|
||||
const hasText = Boolean(description && description.trim().length > 0)
|
||||
|
||||
return (
|
||||
<div className="bg-surface/50 rounded-lg p-4 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-2">Açıklama</h2>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-line">
|
||||
{description}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<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}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
title: string
|
||||
price: string
|
||||
store: string
|
||||
link: 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({
|
||||
|
|
@ -17,27 +47,90 @@ export default function DealDetails({
|
|||
postedBy,
|
||||
postedAgo,
|
||||
}: DealDetailsProps) {
|
||||
const hasLink = Boolean(link && link.trim().length > 0)
|
||||
const timeAgo = useMemo(() => formatTimeAgo(postedAgo), [postedAgo])
|
||||
|
||||
return (
|
||||
<div className="bg-surface/50 rounded-lg p-4 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold mb-2">{title}</h1>
|
||||
<div className="rounded-3xl bg-surface border border-border p-6">
|
||||
{/* 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>
|
||||
|
||||
<div className="text-sm text-muted-foreground mb-4">
|
||||
Mağaza: {store}
|
||||
{store ? (
|
||||
<span className="shrink-0 rounded-full px-3 py-1 text-xs font-semibold bg-background border border-border text-primary">
|
||||
{store}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-blue-600 hover:underline mb-4"
|
||||
>
|
||||
Anlaşmayı Gör
|
||||
</a>
|
||||
{/* 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>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paylaşan: {postedBy} • {postedAgo} önce
|
||||
{/* 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
|
||||
href={hasLink ? link : undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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(" ")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Fırsata Git
|
||||
</a>
|
||||
|
||||
<button
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,114 @@
|
|||
import { useMemo, useState, useEffect } from "react"
|
||||
import type { DealImage } from "../../models/deal/DealImage"
|
||||
|
||||
type DealImagesProps = {
|
||||
imageUrl: string
|
||||
images: DealImage[]
|
||||
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 (
|
||||
<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 bg-surface rounded-lg overflow-hidden shadow-sm">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={alt ?? "deal image"}
|
||||
className="w-full h-auto object-cover"
|
||||
/>
|
||||
<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
|
||||
src={activeSrc}
|
||||
alt={alt ?? "deal image"}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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,59 +1,77 @@
|
|||
import UserInfo from "./UserInfo"
|
||||
import { Link } from "react-router-dom"
|
||||
import SearchBar from "../Navbar/SearchBar"
|
||||
import ThemeToggle from "./ThemeToggle"
|
||||
import { useHideOnScroll } from "../../../hooks/useHideOnScroll"
|
||||
|
||||
export default function Navbar() {
|
||||
const visible = useHideOnScroll({
|
||||
topThreshold: 12,
|
||||
hideAfterDownPx: 12,
|
||||
revealAfterUpPx: 150, // artır: daha geç gelsin
|
||||
})
|
||||
return (
|
||||
<nav className="bg-surface">
|
||||
<div className="mx-auto flex justify-between items-center px-6 py-3">
|
||||
{/* Sol kısım: logo + menü */}
|
||||
<div className="flex items-center gap-10">
|
||||
<div className="text-primary font-bold text-xl">DealHeat</div>
|
||||
<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">
|
||||
{/* Sol: logo + menü */}
|
||||
<div className="flex items-center gap-10">
|
||||
<Link to="/" className="text-primary font-bold text-xl">
|
||||
DealHeat
|
||||
</Link>
|
||||
|
||||
<ul className="flex gap-6 text-text items-center">
|
||||
<li>
|
||||
<Link
|
||||
to="/"
|
||||
className="hover:text-primary transition-colors font-semibold"
|
||||
>
|
||||
Anasayfa
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-primary transition-colors font-semibold"
|
||||
>
|
||||
Fırsatlar
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="#"
|
||||
className="hover:text-primary transition-colors font-semibold"
|
||||
>
|
||||
İletişim
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ul className="flex gap-6 text-text items-center">
|
||||
<li>
|
||||
<Link
|
||||
to="/"
|
||||
className="hover:text-primary transition-colors font-semibold"
|
||||
>
|
||||
Anasayfa
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/deals"
|
||||
className="hover:text-primary transition-colors font-semibold"
|
||||
>
|
||||
Fırsatlar
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="hover:text-primary transition-colors font-semibold"
|
||||
>
|
||||
İletişim
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Orta kısım: arama kutusu */}
|
||||
<div className="flex-1 flex justify-center">
|
||||
<SearchBar />
|
||||
</div>
|
||||
{/* Orta: arama */}
|
||||
<div className="flex-1 flex justify-center px-6">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Sağ kısım: kullanıcı bilgisi + buton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<UserInfo />
|
||||
<Link
|
||||
to="/create-deal"
|
||||
className="bg-primary text-white font-semibold px-4 py-2 rounded-md hover:bg-primary/90 transition"
|
||||
>
|
||||
Fırsat Yolla
|
||||
</Link>
|
||||
{/* Sağ: toggle + kullanıcı + buton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
<UserInfo />
|
||||
<Link
|
||||
to="/create-deal"
|
||||
className="bg-primary hover:bg-primary-hover text-white font-semibold px-4 py-2 rounded-md transition"
|
||||
>
|
||||
Fırsat Yolla
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,18 +14,18 @@ export default function SearchBar() {
|
|||
return (
|
||||
<form
|
||||
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
|
||||
type="text"
|
||||
placeholder="Fırsat ara..."
|
||||
value={query}
|
||||
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
|
||||
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
|
||||
</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 { useAuth } from "../../../context/AuthContext"
|
||||
import LoginModal from "../../Auth/LoginModal"
|
||||
|
|
@ -8,58 +8,122 @@ export default function UserInfo() {
|
|||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
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 = () => {
|
||||
setMenuOpen(false)
|
||||
navigate("/account")
|
||||
}
|
||||
|
||||
const goToProfile = () => {
|
||||
if (!user?.username) return
|
||||
setMenuOpen(false)
|
||||
navigate(`/user/${user.username}`)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
const avatarSrc =
|
||||
user?.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-3">
|
||||
<div className="relative flex items-center gap-3" ref={menuRef}>
|
||||
{isAuthenticated && user ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||
alt={user.username}
|
||||
className="w-8 h-8 rounded-full cursor-pointer border border-gray-400 hover:border-orange-500 transition"
|
||||
<button
|
||||
type="button"
|
||||
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 && (
|
||||
<div className="absolute right-0 mt-2 w-44 bg-surface border border-border rounded-lg shadow-md py-2 z-50">
|
||||
<p className="px-4 py-2 text-sm text-text font-semibold">{user.username}</p>
|
||||
<div
|
||||
className="absolute right-0 mt-2 w-56 bg-surface border border-border rounded-xl shadow-lg z-50 overflow-hidden"
|
||||
role="menu"
|
||||
>
|
||||
{/* Entire row clickable */}
|
||||
<button
|
||||
onClick={goToAccount}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
type="button"
|
||||
onClick={goToProfile}
|
||||
className="w-full text-left px-4 py-3 border-b border-border hover:bg-surface-2 transition"
|
||||
role="menuitem"
|
||||
>
|
||||
Profilim
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
|
||||
>
|
||||
Çıkış yap
|
||||
<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}
|
||||
className="w-full text-left px-4 py-2.5 text-sm text-danger hover:bg-surface-2 transition"
|
||||
role="menuitem"
|
||||
>
|
||||
Çıkış yap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowModal(true)}
|
||||
className="bg-orange-600 text-white px-4 py-2 rounded-md hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
Giriş yap
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(true)}
|
||||
className="bg-[var(--color-primary)] text-white px-4 py-2 rounded-lg hover:bg-[var(--color-primary-hover)] transition-colors"
|
||||
>
|
||||
Giriş yap
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<LoginModal onClose={() => setShowModal(false)} />
|
||||
)}
|
||||
{showModal && <LoginModal onClose={() => setShowModal(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,62 @@
|
|||
import { Link } from "react-router-dom"
|
||||
import { MessageCircle, ExternalLink } from "lucide-react"
|
||||
import type { Comment } from "../../models"
|
||||
import { timeAgo } from "../../utils/timeAgo"
|
||||
|
||||
type CommentCardProps = {
|
||||
comment: Comment
|
||||
}
|
||||
|
||||
export default function CommentCard({ comment }: CommentCardProps) {
|
||||
const dealTitle = comment.deal?.title ?? "-"
|
||||
const dealId = comment.deal?.id
|
||||
const created = comment.createdAt
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-3 text-sm">
|
||||
<p className="font-medium mb-1">{comment.deal.title}</p>
|
||||
<p>{comment.text}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{new Date(comment.createdAt).toLocaleString("tr-TR")}
|
||||
</p>
|
||||
<div className="rounded-2xl bg-surface border border-border p-4 hover:border-border/70 transition">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
{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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,83 @@
|
|||
// src/components/Profile/ProfileHeader.tsx
|
||||
import { Heart, MessageCircle, Share2 } from "lucide-react"
|
||||
|
||||
type Props = {
|
||||
username: 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 (
|
||||
<div className="w-full bg-surface/50 rounded-lg flex flex-col items-center justify-center py-12">
|
||||
<div className="relative w-32 h-32 mb-4">
|
||||
<img
|
||||
src={avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||
alt={username}
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-background"
|
||||
/>
|
||||
<div className="w-full rounded-3xl bg-surface border border-border overflow-hidden">
|
||||
{/* 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
|
||||
src={
|
||||
avatarUrl ||
|
||||
`${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`
|
||||
}
|
||||
alt={username}
|
||||
className="w-full h-full rounded-full object-cover border-4 border-surface shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<h2 className="text-2xl font-semibold text-center">{username}</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from "react"
|
|||
import { useNavigate } from "react-router-dom"
|
||||
import { voteDeal } from "../../api/deal/voteDeal"
|
||||
import { useAuth } from "../../hooks/useAuth"
|
||||
import { ChevronUp, ChevronDown, MessageCircle, Share2 } from "lucide-react"
|
||||
|
||||
type DealCardProps = {
|
||||
id: number
|
||||
|
|
@ -13,7 +14,8 @@ type DealCardProps = {
|
|||
score: number
|
||||
comments: number
|
||||
postedAgo: string
|
||||
onRequireLogin: () => void // yeni prop
|
||||
myVote: 1 | 0 | -1
|
||||
onRequireLogin: () => void
|
||||
}
|
||||
|
||||
export default function DealCardMain({
|
||||
|
|
@ -26,22 +28,22 @@ export default function DealCardMain({
|
|||
score,
|
||||
comments,
|
||||
postedAgo,
|
||||
myVote,
|
||||
onRequireLogin,
|
||||
}: DealCardProps) {
|
||||
|
||||
const [currentScore, setCurrentScore] = useState<number>(score)
|
||||
const [currentVote, setCurrentVote] = useState<1 | 0 | -1>(myVote)
|
||||
const [voting, setVoting] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const auth = useAuth() // her render'da güncel context
|
||||
const auth = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentScore(score)
|
||||
}, [score])
|
||||
useEffect(() => setCurrentScore(score), [score])
|
||||
useEffect(() => setCurrentVote(myVote), [myVote])
|
||||
|
||||
const handleVote = async (e: React.MouseEvent, type: "UP" | "DOWN") => {
|
||||
const handleVote = async (e: React.MouseEvent, nextVote: 1 | 0 | -1) => {
|
||||
e.stopPropagation()
|
||||
|
||||
// Context’ten anlık token kontrolü
|
||||
const { token, isAuthenticated } = auth
|
||||
if (!isAuthenticated || !token) {
|
||||
onRequireLogin()
|
||||
|
|
@ -49,78 +51,142 @@ export default function DealCardMain({
|
|||
}
|
||||
|
||||
setVoting(true)
|
||||
|
||||
const prevVote = currentVote
|
||||
const prevScore = currentScore
|
||||
|
||||
const delta = nextVote - prevVote
|
||||
setCurrentVote(nextVote)
|
||||
setCurrentScore(prevScore + delta)
|
||||
|
||||
try {
|
||||
const data = await voteDeal( id, type)
|
||||
if (typeof data.score === "number") setCurrentScore(data.score)
|
||||
else alert(data.error || "Oy gönderilemedi")
|
||||
const data = await voteDeal(id, nextVote)
|
||||
if (typeof data.score === "number") {
|
||||
setCurrentScore(data.score)
|
||||
} else {
|
||||
setCurrentVote(prevVote)
|
||||
setCurrentScore(prevScore)
|
||||
alert(data.error || "Oy gönderilemedi")
|
||||
}
|
||||
} catch {
|
||||
setCurrentVote(prevVote)
|
||||
setCurrentScore(prevScore)
|
||||
alert("Sunucu hatası")
|
||||
} finally {
|
||||
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 (
|
||||
<div
|
||||
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
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-full rounded-md object-scale-down"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between flex-1 ml-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center text-l text-text-muted">
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
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>
|
||||
{/* Content */}
|
||||
<div className="flex flex-col justify-between flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span>{postedAgo}</span>
|
||||
<span className="opacity-50">•</span>
|
||||
<span className="truncate">{postedBy} paylaştı</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-text mt-1 hover:text-primary line-clamp-2">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-primary font-bold text-xl">{price}</span>
|
||||
<span className="text-sm text-text-muted">{store}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-text mt-1 hover:text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
{/* Vote pill */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 flex items-center gap-1 rounded-full bg-background border border-white/10 p-1"
|
||||
>
|
||||
<button
|
||||
disabled={voting}
|
||||
onClick={handleUp}
|
||||
className={[
|
||||
"p-2 rounded-full transition",
|
||||
currentVote === 1 ? "text-green-500" : "text-text-muted hover:text-text",
|
||||
voting ? "opacity-60 cursor-not-allowed" : "",
|
||||
].join(" ")}
|
||||
aria-label="Upvote"
|
||||
>
|
||||
<ChevronUp className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-primary font-bold text-xl">{price}</span>
|
||||
<span className="text-sm text-text-muted">{store}</span>
|
||||
<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>
|
||||
|
||||
<p className="text-sm text-text-muted mt-1 line-clamp-2">
|
||||
{postedBy} tarafından paylaşıldı.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<div className="flex gap-4 text-sm text-text-muted">
|
||||
<span>💬 {comments}</span>
|
||||
<span>🔗 Paylaş</span>
|
||||
{/* 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()}
|
||||
className="bg-primary text-white px-4 py-2 rounded-md hover:bg-primary/90"
|
||||
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
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -2,18 +2,72 @@
|
|||
|
||||
@import "tailwindcss";
|
||||
|
||||
/* -------------------------------------------------
|
||||
THEME TOKENS (Tailwind v4 @theme değişkenleri)
|
||||
Default: LIGHT
|
||||
Dark: .dark class'ı ile override
|
||||
-------------------------------------------------- */
|
||||
|
||||
@theme {
|
||||
--color-background: #121212;
|
||||
--color-surface: #1E1E1E;
|
||||
--color-primary: #FF6B00;
|
||||
--color-primary-hover: #E65A00;
|
||||
--color-accent: #FFD166;
|
||||
--color-text: #FFFFFF;
|
||||
--color-text-muted: #B3B3B3;
|
||||
--color-success: #00C851;
|
||||
--color-danger: #FF4444;
|
||||
/* LIGHT (soft graphite) */
|
||||
--color-background: #D6DAE1; /* sayfa: açık gri ama beyaz değil */
|
||||
--color-surface: #E1E5EB; /* kart */
|
||||
--color-surface-2: #CBD1DA; /* input/secondary */
|
||||
--color-border: #B3BBC7; /* border */
|
||||
|
||||
--color-text: #1C212B; /* koyu gri */
|
||||
--color-text-muted: #5D6675;
|
||||
|
||||
--color-primary: #FF6A00;
|
||||
--color-primary-hover: #E85F00;
|
||||
--color-primary-soft: rgba(255, 106, 0, 0.14);
|
||||
--color-primary-ring: rgba(255, 106, 0, 0.30);
|
||||
|
||||
--color-success: #16A34A;
|
||||
--color-danger: #EF4444;
|
||||
|
||||
--color-on-primary: #111318;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
@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 Footer from "../components/Layout/Footer";
|
||||
import Navbar from "../components/Layout/Navbar/Navbar"
|
||||
import Footer from "../components/Layout/Footer"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: Props) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background text-text">
|
||||
{/* Navbar fixed + full width */}
|
||||
<Navbar />
|
||||
|
||||
{/* NAVBAR - tam genişlikte arka plan, ortalı içerik */}
|
||||
<div className="bg-surface">
|
||||
{/* Content: navbar yüksekliği kadar boşluk */}
|
||||
<main className="flex-1 bg-background pt-14">
|
||||
<div className="max-w-[1400px] mx-auto px-4">
|
||||
<Navbar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ANA İÇERİK */}
|
||||
<main className="flex-1 bg-background">
|
||||
<div className="max-w-[1400px] mx-auto px-4 ">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* FOOTER - tam genişlikte arka plan, ortalı içerik */}
|
||||
<div className="bg-surface border-t border-zinc-800">
|
||||
{/* Footer */}
|
||||
<div className="bg-surface border-t border-border">
|
||||
<div className="max-w-[1400px] mx-auto px-4 py-4">
|
||||
<Footer />
|
||||
</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
|
||||
price?: number
|
||||
imageUrl: string
|
||||
|
||||
images?: File[] // max 5
|
||||
|
||||
seller: Seller
|
||||
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"
|
||||
|
||||
export type DealCard = {
|
||||
|
|
@ -12,6 +12,8 @@ export type DealCard = {
|
|||
score: number
|
||||
commentsCount: number
|
||||
|
||||
myVote:-1 | 0 | 1
|
||||
|
||||
status: "PENDING" | "ACTIVE" | "EXPIRED" | "REJECTED"
|
||||
saleType: "ONLINE" | "OFFLINE" | "CODE"
|
||||
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 { DealImage } from "./DealImage"
|
||||
import type { DealComment } from "./DealComment"
|
||||
import type { Comment } from "../comment/Comment"
|
||||
export type DealDetail = {
|
||||
id: number
|
||||
title: string
|
||||
|
|
@ -23,5 +23,5 @@ export type DealDetail = {
|
|||
user: PublicUserSummary
|
||||
seller: SellerSummary
|
||||
images: DealImage[]
|
||||
comments: DealComment[]
|
||||
comments: Comment[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type DealImage = {
|
||||
url: string
|
||||
imageUrl: string
|
||||
order: number
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ export type DealVote = {
|
|||
id: number
|
||||
dealId: number
|
||||
userId: number
|
||||
voteType: string
|
||||
voteType: number
|
||||
createdAt: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export * from "./User"
|
||||
export * from "./Deal"
|
||||
export * from "./user/User"
|
||||
export * from "./deal/DealImage"
|
||||
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 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 { lookupSellerFromLink } from "../api/seller/from-lookup"
|
||||
|
||||
import { mapSellerFromLookupRequest } from "../adapters/requests/sellerFromLookupAdapter"
|
||||
|
||||
import { mapSellerFromLookupResponse } from "../adapters/responses/sellerFromLookupAdapter"
|
||||
import { mapDealDraftToCreateRequest } from "../adapters/requests/dealCreateAdapter.ts"
|
||||
|
||||
import DealLinkStep from "../components/CreateDeal/DealLinkStep"
|
||||
import DealDetailsStep from "../components/CreateDeal/DealDetailsStep"
|
||||
|
||||
import type { SellerLookupInput } from "../api/seller/types.ts"
|
||||
import type { DealDraft } from "../models/DealDraft"
|
||||
import type { Seller } from "../models/seller/Seller"
|
||||
|
||||
|
|
@ -26,6 +27,7 @@ export default function CreateDealPage() {
|
|||
url: "",
|
||||
price: undefined,
|
||||
imageUrl: "",
|
||||
images: [], // <-- ekle
|
||||
seller: {
|
||||
id: -1,
|
||||
name: "",
|
||||
|
|
@ -51,29 +53,18 @@ export default function CreateDealPage() {
|
|||
|
||||
try {
|
||||
// 🔥 1. URL → Seller (temporary)
|
||||
const tempSeller: Seller = {
|
||||
id: -1,
|
||||
name: "",
|
||||
url: dealDraft.url,
|
||||
}
|
||||
|
||||
|
||||
// 🔥 2. Seller → Lookup Request (ADAPTER)
|
||||
const lookupRequest =
|
||||
mapSellerFromLookupRequest(tempSeller)
|
||||
|
||||
const input: SellerLookupInput = { url: dealDraft.url }
|
||||
// 🔥 3. API CALL
|
||||
const lookupResponse =
|
||||
await lookupSellerFromLink(lookupRequest)
|
||||
|
||||
// 🔥 4. Response → Seller (ADAPTER)
|
||||
const seller =
|
||||
mapSellerFromLookupResponse(lookupResponse)
|
||||
|
||||
setDealDraft(d => ({
|
||||
...d,
|
||||
seller,
|
||||
}))
|
||||
const seller = await lookupSellerFromLink(input)
|
||||
|
||||
setDealDraft(d => {
|
||||
const next = { ...d, seller }
|
||||
console.log("NEXT:", next)
|
||||
return next
|
||||
})
|
||||
setStep("details")
|
||||
} catch (err) {
|
||||
console.error("Seller lookup failed:", err)
|
||||
|
|
@ -97,6 +88,7 @@ export default function CreateDealPage() {
|
|||
|
||||
const handleFinalSubmit = async () => {
|
||||
try {
|
||||
|
||||
await createDeal(
|
||||
mapDealDraftToCreateRequest(dealDraft)
|
||||
)
|
||||
|
|
@ -115,6 +107,7 @@ export default function CreateDealPage() {
|
|||
url: "",
|
||||
price: undefined,
|
||||
imageUrl: "",
|
||||
images: [],
|
||||
seller: {
|
||||
id: -1,
|
||||
name: "",
|
||||
|
|
@ -127,7 +120,8 @@ export default function CreateDealPage() {
|
|||
|
||||
return (
|
||||
<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" && (
|
||||
<DealLinkStep
|
||||
url={dealDraft.url}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import DealDescription from "../components/DealDetails/DealDescription"
|
|||
import DealComments from "../components/DealDetails/DealComments"
|
||||
|
||||
import { getDealDetail } from "../api/deal/getDeal"
|
||||
import { mapDealDetailResponseToDealDetail } from "../adapters/responses/dealDetailAdapter"
|
||||
import type { DealDetail } from "../models/deal/DealDetail"
|
||||
|
||||
type DealPageProps = {
|
||||
|
|
@ -22,54 +21,71 @@ export default function DealPage({ onRequireLogin }: DealPageProps) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
|
||||
const loadDeal = async () => {
|
||||
;(async () => {
|
||||
try {
|
||||
const apiDeal = await getDealDetail(Number(id))
|
||||
const mapped = mapDealDetailResponseToDealDetail(apiDeal)
|
||||
setDeal(mapped)
|
||||
const d = await getDealDetail(Number(id))
|
||||
setDeal(d)
|
||||
} catch (err) {
|
||||
console.error("Deal yüklenemedi:", err)
|
||||
}
|
||||
}
|
||||
|
||||
loadDeal()
|
||||
})()
|
||||
}, [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 (
|
||||
<MainLayout>
|
||||
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sol: görseller */}
|
||||
<div className="lg:col-span-1 flex justify-center items-start">
|
||||
<DealImages imageUrl={deal.images[0].url} />
|
||||
</div>
|
||||
<div className="max-w-[1400px] mx-auto px-4 py-8">
|
||||
{/* üst ana grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* 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>
|
||||
|
||||
{/* Sağ: temel bilgiler */}
|
||||
<div className="lg:col-span-3 flex flex-col gap-6">
|
||||
<DealDetails
|
||||
title={deal.title}
|
||||
price={deal.price?.toString() ?? "-"}
|
||||
store={deal.seller.name}
|
||||
link={deal.url ?? ""}
|
||||
postedBy={deal.user.username}
|
||||
postedAgo={deal.createdAt}
|
||||
/>
|
||||
</div>
|
||||
{/* SAĞ: Detay kartı (sticky) */}
|
||||
<div className="lg:col-span-5">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
|
||||
{/* Alt: açıklama + yorumlar */}
|
||||
<div className="lg:col-span-4 flex flex-col gap-6">
|
||||
<section>
|
||||
<DealDescription description={deal.description} />
|
||||
</section>
|
||||
<DealDetails
|
||||
title={deal.title}
|
||||
price={deal.price?.toString() ?? "-"}
|
||||
store={deal.seller.name}
|
||||
link={deal.url ?? ""}
|
||||
postedBy={deal.user.username}
|
||||
postedAgo={deal.createdAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* küçük yan bilgi alanı */}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</MainLayout>
|
||||
|
|
|
|||
|
|
@ -25,15 +25,15 @@ export default function HomePage({ onRequireLogin }: HomeProps) {
|
|||
setLoading(true)
|
||||
|
||||
try {
|
||||
const apiDeals = await getDeals(page)
|
||||
if (apiDeals.length === 0) {
|
||||
const deals = await getDeals(page)
|
||||
if (deals.length === 0) {
|
||||
setHasMore(false)
|
||||
} else {
|
||||
const mappedDeals = apiDeals.map(mapDealCardResponseToDeal)
|
||||
|
||||
|
||||
setDeals((prev) => {
|
||||
const existingIds = new Set(prev.map((d) => d.id))
|
||||
const filtered = mappedDeals.filter(
|
||||
const filtered = deals.filter(
|
||||
(d) => !existingIds.has(d.id)
|
||||
)
|
||||
return [...prev, ...filtered]
|
||||
|
|
@ -84,8 +84,10 @@ export default function HomePage({ onRequireLogin }: HomeProps) {
|
|||
score={deal.score}
|
||||
comments={deal.commentsCount}
|
||||
postedAgo={timeAgo(deal.createdAt)}
|
||||
myVote={deal.myVote}
|
||||
onRequireLogin={onRequireLogin}
|
||||
/>
|
||||
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
|
|
|
|||
|
|
@ -3,80 +3,89 @@ import { useParams } from "react-router-dom"
|
|||
import MainLayout from "../layouts/MainLayout"
|
||||
import DealCardMain from "../components/Shared/DealCardMain"
|
||||
import CommentCard from "../components/Profile/CommentCard"
|
||||
import ProfileHeader from "../components/Profile/ProfileHeader"
|
||||
import { fetchUserProfile } from "../services/userService"
|
||||
import type { Deal, Comment } from "../models"
|
||||
import type { PublicUserSummary } from "../models/User"
|
||||
import { timeAgo } from "../utils/timeAgo"
|
||||
|
||||
import type { UserProfile } from "../models/user/UserProfile"
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { userName } = useParams<{ userName: string }>()
|
||||
const [activeTab, setActiveTab] = useState<"deals" | "comments">("deals")
|
||||
const [user, setUser] = useState<PublicUserSummary | null>(null)
|
||||
const [deals, setDeals] = useState<Deal[]>([])
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [userProfile, setUserProfile] = useState<UserProfile | null>(null)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!userName) return
|
||||
|
||||
const loadUser = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { user, deals, comments } = await fetchUserProfile(userName)
|
||||
setUser(user)
|
||||
setDeals(deals)
|
||||
setComments(comments)
|
||||
const profile = await fetchUserProfile(userName)
|
||||
setUserProfile(profile)
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
setError(err.message)
|
||||
setError(err.message || "Sunucu hatası")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadUser()
|
||||
}, [userName])
|
||||
|
||||
if (loading) return <p className="p-4 text-center">Yükleniyor...</p>
|
||||
if (error) return <p className="p-4 text-center text-red-600">{error}</p>
|
||||
if (!user) return <p className="p-4 text-center">Kullanıcı bulunamadı.</p>
|
||||
if (loading) return <p className="p-6 text-center text-text-muted">Yükleniyor...</p>
|
||||
if (error) return <p className="p-6 text-center text-danger">{error}</p>
|
||||
if (!userProfile) return <p className="p-6 text-center text-text-muted">Kullanıcı bulunamadı.</p>
|
||||
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
||||
{/* ÜST: profil bilgisi */}
|
||||
<div className="bg-surface/50 rounded-lg p-6 flex flex-col items-center text-center shadow-sm">
|
||||
<img
|
||||
src={user.avatarUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-profile.png`}
|
||||
alt="avatar"
|
||||
className="w-24 h-24 rounded-full mb-3 border"
|
||||
/>
|
||||
<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>
|
||||
{/* ÜST: profil header */}
|
||||
<ProfileHeader
|
||||
username={userProfile.user.username}
|
||||
avatarUrl={userProfile.user.avatarUrl ?? undefined}
|
||||
totalLikes={userProfile.stats.totalLikes}
|
||||
totalShares={userProfile.stats.totalShares}
|
||||
totalComments={userProfile.stats.totalComments}
|
||||
/>
|
||||
|
||||
{/* MENÜ */}
|
||||
<div className="border-b border-border flex justify-center gap-8 text-sm font-medium">
|
||||
{/* TAB BAR */}
|
||||
<div className="rounded-2xl bg-surface border border-border p-2 flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("deals")}
|
||||
className={`py-2 ${
|
||||
className={[
|
||||
"px-4 py-2 rounded-xl text-sm font-semibold transition",
|
||||
activeTab === "deals"
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
? "bg-background border border-border text-primary"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
Paylaşımlar
|
||||
<span className="ml-2 text-xs text-text-muted">
|
||||
{userProfile.deals?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab("comments")}
|
||||
className={`py-2 ${
|
||||
className={[
|
||||
"px-4 py-2 rounded-xl text-sm font-semibold transition",
|
||||
activeTab === "comments"
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
? "bg-background border border-border text-primary"
|
||||
: "text-text-muted hover:text-text",
|
||||
].join(" ")}
|
||||
>
|
||||
Yorumlar
|
||||
<span className="ml-2 text-xs text-text-muted">
|
||||
{userProfile.comments?.length ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -84,43 +93,47 @@ export default function ProfilePage() {
|
|||
<div className="min-h-[300px]">
|
||||
{activeTab === "deals" ? (
|
||||
<div className="space-y-4">
|
||||
{deals.length > 0 ? (
|
||||
deals.map((deal) => {
|
||||
const firstImage = deal.images?.[0]?.imageUrl || "/placeholder.png"
|
||||
const postedAgo = new Date(deal.createdAt).toLocaleDateString("tr-TR")
|
||||
|
||||
return (
|
||||
<DealCardMain
|
||||
key={deal.id}
|
||||
id={deal.id}
|
||||
image={firstImage}
|
||||
title={deal.title}
|
||||
price={`${deal.price ?? 0}₺`}
|
||||
store={ "Bilinmiyor"}
|
||||
postedBy={user.username}
|
||||
score={deal.score}
|
||||
comments={0}
|
||||
postedAgo={postedAgo}
|
||||
onRequireLogin={() => {}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Henüz paylaşım yok.
|
||||
</p>
|
||||
)}
|
||||
{userProfile.deals?.length > 0 ? (
|
||||
userProfile.deals.map((deal) => (
|
||||
<DealCardMain
|
||||
key={deal.id}
|
||||
id={deal.id}
|
||||
image={deal.imageUrl || `${import.meta.env.BASE_URL}placeholders/placeholder-deal.png`}
|
||||
title={deal.title}
|
||||
price={deal.price != null ? `${deal.price}₺` : ""}
|
||||
|
||||
store={deal.seller?.name ?? ""}
|
||||
postedBy={deal.user?.username ?? "unknown"}
|
||||
score={deal.score}
|
||||
comments={deal.commentsCount}
|
||||
postedAgo={timeAgo(deal.createdAt)}
|
||||
myVote={deal.myVote}
|
||||
onRequireLogin={() => {}}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
||||
<div className="text-sm font-semibold text-text">Henüz paylaşım yok</div>
|
||||
<div className="text-xs text-text-muted mt-1">
|
||||
Bu kullanıcı daha fırsat paylaşmamış.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{comments.length > 0 ? (
|
||||
comments.map((c) => <CommentCard key={c.id} comment={c} />)
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
Henüz yorum yok.
|
||||
</p>
|
||||
)}
|
||||
{userProfile.comments?.length > 0 ? (
|
||||
userProfile.comments.map((c) => (
|
||||
<CommentCard key={c.id} comment={c} />
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-2xl bg-surface border border-border p-6 text-center">
|
||||
<div className="text-sm font-semibold text-text">Henüz yorum yok</div>
|
||||
<div className="text-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
|
||||
key={deal.id}
|
||||
id={deal.id}
|
||||
image={deal.images[0]?.imageUrl || "/placeholder.png"}
|
||||
image={deal.imageUrl || "/placeholder.png"}
|
||||
title={deal.title}
|
||||
price={`${deal.price}₺`}
|
||||
store={deal.url ? new URL(deal.url).hostname : "Bilinmiyor"}
|
||||
postedBy={deal.user?.username || "unknown"}
|
||||
price={deal.price ? `${deal.price}₺` : ""}
|
||||
store={deal.seller.name}
|
||||
postedBy={deal.user?.username ?? "unknown"}
|
||||
score={deal.score}
|
||||
comments={0}
|
||||
comments={deal.commentsCount}
|
||||
postedAgo={timeAgo(deal.createdAt)}
|
||||
onRequireLogin={onRequireLogin}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,41 +1,7 @@
|
|||
import { getUser } from "../api/user/getUser"
|
||||
import type { PublicUserSummary, Deal, Comment } from "../models"
|
||||
|
||||
export type UserProfile = {
|
||||
user: PublicUserSummary
|
||||
deals: Deal[]
|
||||
comments: Comment[]
|
||||
}
|
||||
import type { UserProfile } from "../models/user/UserProfile"
|
||||
|
||||
export async function fetchUserProfile(userName: string): Promise<UserProfile> {
|
||||
const data = await getUser(userName)
|
||||
|
||||
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 }
|
||||
return data
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user