Add avatarUrl field to User schema and Supabase setup
This commit is contained in:
parent
ac7a47c911
commit
c577e89218
14
src/api/auth/login.ts
Normal file
14
src/api/auth/login.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export async function login(email: string, password: string) {
|
||||
const res = await fetch("http://localhost:3000/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.message || "Giriş başarısız")
|
||||
}
|
||||
|
||||
return res.json() // { token, user }
|
||||
}
|
||||
14
src/api/auth/register.ts
Normal file
14
src/api/auth/register.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export async function register(username: string, email: string, password: string) {
|
||||
const res = await fetch("http://localhost:3000/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.message || "Kayıt başarısız")
|
||||
}
|
||||
|
||||
return res.json() // { token, user }
|
||||
}
|
||||
24
src/api/deal/commentDeal.ts
Normal file
24
src/api/deal/commentDeal.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const API_URL = "http://localhost:3000/api/comments"
|
||||
|
||||
|
||||
export async function getComments(dealId: number) {
|
||||
const res = await fetch(`${API_URL}/${dealId}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Yorumlar alınamadı")
|
||||
return data
|
||||
}
|
||||
|
||||
export async function postComment(token: string, dealId: number, text: string) {
|
||||
const res = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ dealId, text }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Yorum gönderilemedi")
|
||||
return data
|
||||
}
|
||||
22
src/api/deal/getDeal.ts
Normal file
22
src/api/deal/getDeal.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const API_URL = "http://localhost:3000/api"
|
||||
|
||||
export async function getDeals(page = 1) {
|
||||
try {
|
||||
const res = await fetch(`http://localhost:3000/api/deals?page=${page}`)
|
||||
if (!res.ok) throw new Error("Deal listesi alınamadı")
|
||||
|
||||
const data = await res.json()
|
||||
// Sadece results dizisini döndür
|
||||
return data.results
|
||||
} catch (err) {
|
||||
console.error("Deal listesi hatası:", err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getDeal(id: number) {
|
||||
const res = await fetch(`${API_URL}/deals/${id}`)
|
||||
if (!res.ok) throw new Error("Deal alınamadı")
|
||||
return res.json()
|
||||
}
|
||||
22
src/api/deal/newDeal.ts
Normal file
22
src/api/deal/newDeal.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const API_URL = "http://localhost:3000/api"
|
||||
|
||||
export async function createDeal(token: string, dealData: {
|
||||
title: string
|
||||
description?: string
|
||||
url?: string
|
||||
imageUrl?: string
|
||||
price?: number
|
||||
}) {
|
||||
const res = await fetch(`${API_URL}/deals`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(dealData),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw new Error(data.error || "Fırsat eklenemedi")
|
||||
return data
|
||||
}
|
||||
14
src/api/deal/voteDeal.ts
Normal file
14
src/api/deal/voteDeal.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const API_URL = "http://localhost:3000/api"
|
||||
|
||||
export async function voteDeal(token: string, dealId: number, type: "UP" | "DOWN") {
|
||||
const res = await fetch(`${API_URL}/deal-votes`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ dealId, voteType: type }),
|
||||
})
|
||||
if (!res.ok) throw new Error("Vote hatası")
|
||||
return res.json()
|
||||
}
|
||||
109
src/components/DealScreen/DealComments.tsx
Normal file
109
src/components/DealScreen/DealComments.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React, { useEffect, useState } from "react"
|
||||
import { getComments,postComment } from "../../api/deal/commentDeal"
|
||||
|
||||
|
||||
type Comment = {
|
||||
id: number
|
||||
user: { username: string }
|
||||
text: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type DealCommentsProps = {
|
||||
dealId: number
|
||||
}
|
||||
|
||||
export default function DealComments({ dealId }: DealCommentsProps) {
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const token = localStorage.getItem("token")
|
||||
|
||||
// Yorumları yükle
|
||||
useEffect(() => {
|
||||
async function loadComments() {
|
||||
try {
|
||||
const data = await getComments(dealId)
|
||||
setComments(data)
|
||||
} catch (err) {
|
||||
console.error("Yorumlar alınamadı:", err)
|
||||
}
|
||||
}
|
||||
loadComments()
|
||||
}, [dealId])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newComment.trim()) return
|
||||
if (!token) {
|
||||
alert("Giriş yapmalısın.")
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const added = await postComment(token, dealId, newComment)
|
||||
setComments((prev) => [added, ...prev]) // yeni yorumu ekle
|
||||
setNewComment("")
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Sunucu hatası")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface/50 rounded-lg p-4 shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-4">Yorumlar</h2>
|
||||
|
||||
<ul className="space-y-3 mb-4">
|
||||
{comments.length > 0 ? (
|
||||
comments.map((c) => (
|
||||
<li key={c.id} className="border-b border-border pb-2">
|
||||
<p className="text-sm">
|
||||
<span className="font-medium">{c.user.username}</span>: {c.text}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(c.createdAt).toLocaleString("tr-TR")}
|
||||
</p>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Henüz yorum yok.</p>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{token ? (
|
||||
<form onSubmit={handleSubmit} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Yorum ekle..."
|
||||
className="flex-1 border rounded-md px-3 py-2 text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-primary text-white text-sm px-4 py-2 rounded-md hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Gönderiliyor..." : "Gönder"}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Yorum yazmak için{" "}
|
||||
<a href="/login" className="text-primary font-medium hover:underline">
|
||||
giriş yap
|
||||
</a>{" "}
|
||||
veya{" "}
|
||||
<a href="/register" className="text-primary font-medium hover:underline">
|
||||
kayıt ol
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
src/components/DealScreen/DealDescription.tsx
Normal file
16
src/components/DealScreen/DealDescription.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from "react"
|
||||
|
||||
type DealDescriptionProps = {
|
||||
description: string
|
||||
}
|
||||
|
||||
export default function DealDescription({ description }: DealDescriptionProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
44
src/components/DealScreen/DealDetails.tsx
Normal file
44
src/components/DealScreen/DealDetails.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react"
|
||||
|
||||
type DealDetailsProps = {
|
||||
title: string
|
||||
price: string
|
||||
store: string
|
||||
link: string
|
||||
postedBy: string
|
||||
postedAgo: string
|
||||
}
|
||||
|
||||
export default function DealDetails({
|
||||
title,
|
||||
price,
|
||||
store,
|
||||
link,
|
||||
postedBy,
|
||||
postedAgo,
|
||||
}: DealDetailsProps) {
|
||||
return (
|
||||
<div className="bg-surface/50 rounded-lg p-4 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold mb-2">{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}
|
||||
</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>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Paylaşan: {postedBy} • {postedAgo} önce
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/components/DealScreen/DealImages.tsx
Normal file
17
src/components/DealScreen/DealImages.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
type DealImagesProps = {
|
||||
imageUrl: string
|
||||
alt?: string
|
||||
}
|
||||
|
||||
export default function DealImages({ imageUrl, alt }: DealImagesProps) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,28 +1,53 @@
|
|||
import UserInfo from "./UserInfo";
|
||||
import UserInfo from "./UserInfo"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export default function Navbar() {
|
||||
return (
|
||||
// Dış katman: arka plan tam genişlikte
|
||||
<nav className="bg-surface">
|
||||
{/* İç katman: içeriği ortala ve sınırlı genişlikte tut */}
|
||||
<div className=" mx-auto flex justify-between items-center px-6 py-3">
|
||||
|
||||
<div className="mx-auto flex justify-between items-center px-6 py-3">
|
||||
{/* Sol kısım: logo + menü */}
|
||||
<div className="flex items-center gap-15">
|
||||
{/* Logo */}
|
||||
<div className="text-primary font-bold text-xl">DealHeat</div>
|
||||
|
||||
{/* Menü */}
|
||||
<ul className="flex gap-6 text-text items-center">
|
||||
<li><a href="#" className="hover:text-primary transition-colors font-semibold">Anasayfa</a></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>
|
||||
<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>
|
||||
|
||||
{/* Sağ kısım: kullanıcı bilgisi */}
|
||||
<UserInfo />
|
||||
{/* Sağ kısım: kullanıcı bilgisi + buton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<UserInfo />
|
||||
<Link
|
||||
to="/submit-deal"
|
||||
className="bg-primary text-white font-semibold px-4 py-2 rounded-md hover:bg-primary/90 transition"
|
||||
>
|
||||
Fırsat Yolla
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,91 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import LoginModal from "../../Auth/LoginModal";
|
||||
import { useState, useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import LoginModal from "../../Auth/LoginModal"
|
||||
import { login } from "../../../api/auth/login"
|
||||
import { register } from "../../../api/auth/register"
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
id: number
|
||||
username: string
|
||||
email?: string
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export default function UserInfo() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem("user");
|
||||
if (storedUser) setUser(JSON.parse(storedUser));
|
||||
}, []);
|
||||
const storedUser = localStorage.getItem("user")
|
||||
if (storedUser) setUser(JSON.parse(storedUser))
|
||||
}, [])
|
||||
|
||||
const handleRegister = async (username: string, email: string, password: string) => {
|
||||
const res = await fetch("http://localhost:3000/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
// ...
|
||||
};
|
||||
try {
|
||||
const data = await register(username, email, password)
|
||||
localStorage.setItem("token", data.token)
|
||||
localStorage.setItem("user", JSON.stringify(data.user))
|
||||
setUser(data.user)
|
||||
setShowModal(false)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async (email: string, password: string) => {
|
||||
try {
|
||||
const res = await fetch("http://localhost:3000/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("user", JSON.stringify(data.user));
|
||||
setUser(data.user);
|
||||
setShowModal(false);
|
||||
} else {
|
||||
alert(data.message || "Giriş başarısız");
|
||||
}
|
||||
} catch {
|
||||
alert("Sunucu hatası");
|
||||
const data = await login(email, password)
|
||||
localStorage.setItem("token", data.token)
|
||||
localStorage.setItem("user", JSON.stringify(data.user))
|
||||
setUser(data.user)
|
||||
setShowModal(false)
|
||||
} catch (err: any) {
|
||||
alert(err.message)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("user");
|
||||
setUser(null);
|
||||
};
|
||||
localStorage.removeItem("token")
|
||||
localStorage.removeItem("user")
|
||||
setUser(null)
|
||||
setMenuOpen(false)
|
||||
}
|
||||
|
||||
const goToProfile = () => {
|
||||
setMenuOpen(false)
|
||||
navigate("/profile")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex items-center gap-3">
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={user.avatarUrl || "https://via.placeholder.com/32"}
|
||||
alt={user.username}
|
||||
className="w-8 h-8 rounded-full"
|
||||
/>
|
||||
<span className="font-medium text-white">{user.username}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="bg-gray-700 text-white px-3 py-1 rounded hover:bg-gray-600 text-sm"
|
||||
>
|
||||
Çıkış yap
|
||||
</button>
|
||||
<div className="relative">
|
||||
<img
|
||||
src={user.avatarUrl || "https://via.placeholder.com/32"}
|
||||
alt={user.username}
|
||||
className="w-8 h-8 rounded-full cursor-pointer border border-gray-400 hover:border-orange-500 transition"
|
||||
onClick={() => setMenuOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
<button
|
||||
onClick={goToProfile}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
|
|
@ -89,5 +104,5 @@ export default function UserInfo() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { voteDeal } from "../../api/deal/voteDeal" // yeni api dosyan
|
||||
|
||||
type DealCardProps = {
|
||||
id: number;
|
||||
image: string;
|
||||
title: string;
|
||||
price: string;
|
||||
store: string;
|
||||
postedBy: string;
|
||||
score: number;
|
||||
comments: number;
|
||||
postedAgo: string;
|
||||
};
|
||||
id: number
|
||||
image: string
|
||||
title: string
|
||||
price: string
|
||||
store: string
|
||||
postedBy: string
|
||||
score: number
|
||||
comments: number
|
||||
postedAgo: string
|
||||
}
|
||||
|
||||
export default function DealCardMain({
|
||||
id,
|
||||
|
|
@ -23,48 +25,43 @@ export default function DealCardMain({
|
|||
comments,
|
||||
postedAgo,
|
||||
}: DealCardProps) {
|
||||
const [currentScore, setCurrentScore] = useState<number>(score);
|
||||
const [voting, setVoting] = useState(false);
|
||||
const [currentScore, setCurrentScore] = useState<number>(score)
|
||||
const [voting, setVoting] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// dışarıdan gelen score güncellenirse state'i de güncelle
|
||||
useEffect(() => {
|
||||
setCurrentScore(score);
|
||||
}, [score]);
|
||||
setCurrentScore(score)
|
||||
}, [score])
|
||||
|
||||
const handleVote = async (type: "UP" | "DOWN") => {
|
||||
const token = localStorage.getItem("token");
|
||||
const handleVote = async (e: React.MouseEvent, type: "UP" | "DOWN") => {
|
||||
e.stopPropagation()
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) {
|
||||
alert("Giriş yapmalısın");
|
||||
return;
|
||||
alert("Giriş yapmalısın")
|
||||
return
|
||||
}
|
||||
|
||||
setVoting(true);
|
||||
setVoting(true)
|
||||
try {
|
||||
const res = await fetch("http://localhost:3000/api/deal-votes", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ dealId: id, voteType: type }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && typeof data.score === "number") {
|
||||
setCurrentScore(data.score);
|
||||
} else {
|
||||
alert(data.error || "Oy gönderilemedi");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Vote error:", err);
|
||||
alert("Sunucu hatası");
|
||||
const data = await voteDeal(token, id, type)
|
||||
if (typeof data.score === "number") setCurrentScore(data.score)
|
||||
else alert(data.error || "Oy gönderilemedi")
|
||||
} catch {
|
||||
alert("Sunucu hatası")
|
||||
} finally {
|
||||
setVoting(false);
|
||||
setVoting(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleCardClick = () => {
|
||||
navigate(`/deal/${id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex bg-surface p-4 rounded-xl hover:shadow-md transition mb-5">
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="flex bg-surface p-4 rounded-xl hover:shadow-md transition mb-5 cursor-pointer"
|
||||
>
|
||||
<div className="w-42 h-42 flex-shrink-0">
|
||||
<img
|
||||
src={image}
|
||||
|
|
@ -79,7 +76,7 @@ export default function DealCardMain({
|
|||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={voting}
|
||||
onClick={() => handleVote("UP")}
|
||||
onClick={(e) => handleVote(e, "UP")}
|
||||
className="text-green-600 font-bold text-lg"
|
||||
>
|
||||
▲
|
||||
|
|
@ -87,16 +84,16 @@ export default function DealCardMain({
|
|||
<span>{currentScore ?? 0}°</span>
|
||||
<button
|
||||
disabled={voting}
|
||||
onClick={() => handleVote("DOWN")}
|
||||
onClick={(e) => handleVote(e, "DOWN")}
|
||||
className="text-red-600 font-bold text-lg"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
</span>
|
||||
<span>Posted {postedAgo}</span>
|
||||
<span> {postedAgo} </span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-text mt-1 hover:text-primary cursor-pointer">
|
||||
<h2 className="text-xl font-semibold text-text mt-1 hover:text-primary">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
|
|
@ -115,11 +112,14 @@ export default function DealCardMain({
|
|||
<span>💬 {comments}</span>
|
||||
<span>🔗 Paylaş</span>
|
||||
</div>
|
||||
<button className="bg-primary text-white px-4 py-2 rounded-md hover:bg-primary/90">
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="bg-primary text-white px-4 py-2 rounded-md hover:bg-primary/90"
|
||||
>
|
||||
Fırsatı kap
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
31
src/layouts/DealDetailsLayout.tsx
Normal file
31
src/layouts/DealDetailsLayout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
export default function DealDetailsLayout({
|
||||
Image,
|
||||
Details,
|
||||
Description,
|
||||
Comments,
|
||||
}: {
|
||||
Image: React.ReactNode
|
||||
Details: React.ReactNode
|
||||
Description: React.ReactNode
|
||||
Comments: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-4 py-8 grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Üst kısım: sol görsel + sağ detay */}
|
||||
<div className="lg:col-span-1 flex justify-center items-start">
|
||||
{Image}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3 flex flex-col gap-6">
|
||||
{Details}
|
||||
</div>
|
||||
|
||||
{/* Alt kısım: açıklama ve yorumlar tam genişlikte */}
|
||||
<div className="lg:col-span-4 flex flex-col gap-6">
|
||||
<section>{Description}</section>
|
||||
<section>{Comments}</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -3,12 +3,16 @@ import ReactDOM from "react-dom/client";
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom"; // <-- Burası önemli
|
||||
import HomePage from "./pages/HomePage";
|
||||
import "./global.css";
|
||||
|
||||
import DealPage from "./pages/DealPage";
|
||||
import SubmitDealPage from "./pages/SubmitDealPage";
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/deal/:id" element={<DealPage />} />
|
||||
<Route path="/submit-deal" element={<SubmitDealPage />} />
|
||||
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
|
|
|
|||
62
src/pages/DealPage.tsx
Normal file
62
src/pages/DealPage.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useState } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { getDeal } from "../api/deal/getDeal"
|
||||
|
||||
import MainLayout from "../layouts/MainLayout"
|
||||
import DealDetailsLayout from "../layouts/DealDetailsLayout"
|
||||
import DealImages from "../components/DealScreen/DealImages"
|
||||
import DealDetails from "../components/DealScreen/DealDetails"
|
||||
import DealDescription from "../components/DealScreen/DealDescription"
|
||||
import DealComments from "../components/DealScreen/DealComments"
|
||||
|
||||
type Deal = {
|
||||
id: number
|
||||
title: string
|
||||
price: string
|
||||
store: string
|
||||
link: string
|
||||
image: string
|
||||
description: string
|
||||
postedBy: string
|
||||
postedAgo: string
|
||||
comments: {
|
||||
id: number
|
||||
author: string
|
||||
text: string
|
||||
postedAgo: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function DealPage() {
|
||||
const { id } = useParams()
|
||||
const [deal, setDeal] = useState<Deal | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getDeal(Number(id))
|
||||
.then(setDeal)
|
||||
.catch((err) => console.error(err))
|
||||
}, [id])
|
||||
|
||||
if (!deal) return <p className="p-4">Yükleniyor...</p>
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<DealDetailsLayout
|
||||
Image={<DealImages imageUrl={deal.image} />}
|
||||
Details={
|
||||
<DealDetails
|
||||
title={deal.title}
|
||||
price={deal.price}
|
||||
store={deal.store}
|
||||
link={deal.link}
|
||||
postedBy={deal.postedBy}
|
||||
postedAgo={deal.postedAgo}
|
||||
/>
|
||||
}
|
||||
Description={<DealDescription description={deal.description} />}
|
||||
Comments={<DealComments dealId={deal.id} />}
|
||||
/>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,56 +1,97 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import MainLayout from "../layouts/MainLayout";
|
||||
import HomeLayout from "../layouts/HomeLayout";
|
||||
import DealCardMain from "../components/Shared/DealCardMain";
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import MainLayout from "../layouts/MainLayout"
|
||||
import HomeLayout from "../layouts/HomeLayout"
|
||||
import DealCardMain from "../components/Shared/DealCardMain"
|
||||
import { getDeals } from "../api/deal/getDeal"
|
||||
import { timeAgo } from "../utils/timeAgo"
|
||||
|
||||
type Deal = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
url: string;
|
||||
imageUrl: string;
|
||||
price: number;
|
||||
score:number;
|
||||
createdAt: string;
|
||||
user?: { username: string };
|
||||
};
|
||||
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
url: string
|
||||
imageUrl: string
|
||||
price: number
|
||||
score: number
|
||||
createdAt: string
|
||||
user?: { username: string }
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [deals, setDeals] = useState<Deal[]>([]);
|
||||
const [deals, setDeals] = useState<Deal[]>([])
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const observerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// Sayfa değiştiğinde yeni verileri çek
|
||||
useEffect(() => {
|
||||
async function fetchDeals() {
|
||||
const loadDeals = async () => {
|
||||
if (loading || !hasMore) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch("http://localhost:3000/api/deals");
|
||||
const data = await res.json();
|
||||
setDeals(data);
|
||||
} catch (err) {
|
||||
console.error("Error fetching deals:", err);
|
||||
const newDeals = await getDeals(page)
|
||||
if (newDeals.length === 0) {
|
||||
setHasMore(false)
|
||||
} else {
|
||||
// yinelenen veriyi engelle
|
||||
setDeals((prev) => {
|
||||
const existingIds = new Set(prev.map((d) => d.id))
|
||||
const filtered = newDeals.filter((d: Deal) => !existingIds.has(d.id))
|
||||
return [...prev, ...filtered]
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchDeals();
|
||||
}, []);
|
||||
loadDeals()
|
||||
}, [page]) // page değişince tekrar çalışır
|
||||
|
||||
// Scroll gözlemleyici
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loading) {
|
||||
setPage((prev) => prev + 1)
|
||||
}
|
||||
},
|
||||
{ threshold: 1 }
|
||||
)
|
||||
if (observerRef.current) observer.observe(observerRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loading])
|
||||
|
||||
if (error) return <p className="p-4 text-red-600">{error}</p>
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<HomeLayout>
|
||||
{deals.map((deal) => (
|
||||
<DealCardMain
|
||||
key={deal.id}
|
||||
id={deal.id} // <-- eksik olan bu satır
|
||||
image={deal.imageUrl}
|
||||
title={deal.title}
|
||||
price={`${deal.price}₺`}
|
||||
store={new URL(deal.url).hostname}
|
||||
postedBy={deal.user?.username || "unknown"}
|
||||
score={deal.score}
|
||||
comments={0}
|
||||
postedAgo={new Date(deal.createdAt).toLocaleDateString("tr-TR")}
|
||||
/>
|
||||
|
||||
<DealCardMain
|
||||
key={deal.id}
|
||||
id={deal.id}
|
||||
image={deal.imageUrl}
|
||||
title={deal.title}
|
||||
price={`${deal.price}₺`}
|
||||
store={deal.url ? new URL(deal.url).hostname : "Bilinmiyor"}
|
||||
postedBy={deal.user?.username || "unknown"}
|
||||
score={deal.score}
|
||||
comments={0}
|
||||
postedAgo={timeAgo(deal.createdAt)}
|
||||
/>
|
||||
))}
|
||||
{loading && <p className="text-center py-4">Yükleniyor...</p>}
|
||||
{!hasMore && (
|
||||
<p className="text-center py-4 text-muted-foreground">
|
||||
Tüm fırsatlar yüklendi.
|
||||
</p>
|
||||
)}
|
||||
<div ref={observerRef} className="h-8" />
|
||||
</HomeLayout>
|
||||
</MainLayout>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
90
src/pages/SubmitDealPage.tsx
Normal file
90
src/pages/SubmitDealPage.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useState } from "react"
|
||||
import MainLayout from "../layouts/MainLayout"
|
||||
import { createDeal } from "../api/deal/newDeal"
|
||||
|
||||
export default function SubmitDealPage() {
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [url, setUrl] = useState("")
|
||||
const [imageUrl, setImageUrl] = useState("")
|
||||
const [price, setPrice] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const token = localStorage.getItem("token")
|
||||
if (!token) {
|
||||
alert("Fırsat eklemek için giriş yapmalısın.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createDeal(token, {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
imageUrl,
|
||||
price: parseFloat(price),
|
||||
})
|
||||
|
||||
alert("Fırsat başarıyla gönderildi.")
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setUrl("")
|
||||
setImageUrl("")
|
||||
setPrice("")
|
||||
} catch (err: any) {
|
||||
alert(err.message || "Sunucu hatası.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className="max-w-xl mx-auto bg-surface/50 p-6 rounded-lg shadow-sm mt-8">
|
||||
<h1 className="text-2xl font-semibold mb-4 text-primary">Fırsat Yolla</h1>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Başlık"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="border rounded-md px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Açıklama"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="border rounded-md px-3 py-2 h-24"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Ürün Linki"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
className="border rounded-md px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="Görsel URL"
|
||||
value={imageUrl}
|
||||
onChange={(e) => setImageUrl(e.target.value)}
|
||||
className="border rounded-md px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Fiyat"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
className="border rounded-md px-3 py-2"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-primary text-white py-2 rounded-md hover:bg-primary/90 transition"
|
||||
>
|
||||
Gönder
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
13
src/utils/timeAgo.ts
Normal file
13
src/utils/timeAgo.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// src/utils/timeAgo.ts
|
||||
|
||||
export function timeAgo(dateString: string) {
|
||||
const rtf = new Intl.RelativeTimeFormat("tr", { numeric: "auto" })
|
||||
const diffMs = new Date(dateString).getTime() - Date.now()
|
||||
const diffSec = Math.floor(diffMs / 1000)
|
||||
|
||||
const absSec = Math.abs(diffSec)
|
||||
if (absSec < 60) return rtf.format(Math.round(diffSec / 1), "second")
|
||||
if (absSec < 3600) return rtf.format(Math.round(diffSec / 60), "minute")
|
||||
if (absSec < 86400) return rtf.format(Math.round(diffSec / 3600), "hour")
|
||||
return rtf.format(Math.round(diffSec / 86400), "day")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user