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() {
|
export default function Navbar() {
|
||||||
return (
|
return (
|
||||||
// Dış katman: arka plan tam genişlikte
|
|
||||||
<nav className="bg-surface">
|
<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ü */}
|
{/* Sol kısım: logo + menü */}
|
||||||
<div className="flex items-center gap-15">
|
<div className="flex items-center gap-15">
|
||||||
{/* Logo */}
|
|
||||||
<div className="text-primary font-bold text-xl">DealHeat</div>
|
<div className="text-primary font-bold text-xl">DealHeat</div>
|
||||||
|
|
||||||
{/* Menü */}
|
|
||||||
<ul className="flex gap-6 text-text items-center">
|
<ul className="flex gap-6 text-text items-center">
|
||||||
<li><a href="#" className="hover:text-primary transition-colors font-semibold">Anasayfa</a></li>
|
<li>
|
||||||
<li><a href="#" className="hover:text-primary transition-colors font-semibold">Fırsatlar</a></li>
|
<Link
|
||||||
<li><a href="#" className="hover:text-primary transition-colors font-semibold">İletişim</a></li>
|
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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sağ kısım: kullanıcı bilgisi */}
|
{/* Sağ kısım: kullanıcı bilgisi + buton */}
|
||||||
<UserInfo />
|
<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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,91 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react"
|
||||||
import LoginModal from "../../Auth/LoginModal";
|
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 = {
|
type User = {
|
||||||
id: number;
|
id: number
|
||||||
username: string;
|
username: string
|
||||||
email?: string;
|
email?: string
|
||||||
avatarUrl?: string;
|
avatarUrl?: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function UserInfo() {
|
export default function UserInfo() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null)
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = localStorage.getItem("user");
|
const storedUser = localStorage.getItem("user")
|
||||||
if (storedUser) setUser(JSON.parse(storedUser));
|
if (storedUser) setUser(JSON.parse(storedUser))
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const handleRegister = async (username: string, email: string, password: string) => {
|
const handleRegister = async (username: string, email: string, password: string) => {
|
||||||
const res = await fetch("http://localhost:3000/api/auth/register", {
|
try {
|
||||||
method: "POST",
|
const data = await register(username, email, password)
|
||||||
headers: { "Content-Type": "application/json" },
|
localStorage.setItem("token", data.token)
|
||||||
body: JSON.stringify({ username, email, password }),
|
localStorage.setItem("user", JSON.stringify(data.user))
|
||||||
});
|
setUser(data.user)
|
||||||
const data = await res.json();
|
setShowModal(false)
|
||||||
// ...
|
} catch (err: any) {
|
||||||
};
|
alert(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogin = async (email: string, password: string) => {
|
const handleLogin = async (email: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:3000/api/auth/login", {
|
const data = await login(email, password)
|
||||||
method: "POST",
|
localStorage.setItem("token", data.token)
|
||||||
headers: { "Content-Type": "application/json" },
|
localStorage.setItem("user", JSON.stringify(data.user))
|
||||||
body: JSON.stringify({ email, password }),
|
setUser(data.user)
|
||||||
});
|
setShowModal(false)
|
||||||
const data = await res.json();
|
} catch (err: any) {
|
||||||
|
alert(err.message)
|
||||||
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 handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token")
|
||||||
localStorage.removeItem("user");
|
localStorage.removeItem("user")
|
||||||
setUser(null);
|
setUser(null)
|
||||||
};
|
setMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToProfile = () => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
navigate("/profile")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
{user ? (
|
{user ? (
|
||||||
<div className="flex items-center gap-3">
|
<div className="relative">
|
||||||
<div className="flex items-center gap-2">
|
<img
|
||||||
<img
|
src={user.avatarUrl || "https://via.placeholder.com/32"}
|
||||||
src={user.avatarUrl || "https://via.placeholder.com/32"}
|
alt={user.username}
|
||||||
alt={user.username}
|
className="w-8 h-8 rounded-full cursor-pointer border border-gray-400 hover:border-orange-500 transition"
|
||||||
className="w-8 h-8 rounded-full"
|
onClick={() => setMenuOpen((prev) => !prev)}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-white">{user.username}</span>
|
|
||||||
</div>
|
{menuOpen && (
|
||||||
<button
|
<div className="absolute right-0 mt-2 w-44 bg-surface border border-border rounded-lg shadow-md py-2 z-50">
|
||||||
onClick={handleLogout}
|
<p className="px-4 py-2 text-sm text-text font-semibold">{user.username}</p>
|
||||||
className="bg-gray-700 text-white px-3 py-1 rounded hover:bg-gray-600 text-sm"
|
<button
|
||||||
>
|
onClick={goToProfile}
|
||||||
Çıkış yap
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||||
</button>
|
>
|
||||||
|
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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
|
@ -89,5 +104,5 @@ export default function UserInfo() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 = {
|
type DealCardProps = {
|
||||||
id: number;
|
id: number
|
||||||
image: string;
|
image: string
|
||||||
title: string;
|
title: string
|
||||||
price: string;
|
price: string
|
||||||
store: string;
|
store: string
|
||||||
postedBy: string;
|
postedBy: string
|
||||||
score: number;
|
score: number
|
||||||
comments: number;
|
comments: number
|
||||||
postedAgo: string;
|
postedAgo: string
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function DealCardMain({
|
export default function DealCardMain({
|
||||||
id,
|
id,
|
||||||
|
|
@ -23,48 +25,43 @@ export default function DealCardMain({
|
||||||
comments,
|
comments,
|
||||||
postedAgo,
|
postedAgo,
|
||||||
}: DealCardProps) {
|
}: DealCardProps) {
|
||||||
const [currentScore, setCurrentScore] = useState<number>(score);
|
const [currentScore, setCurrentScore] = useState<number>(score)
|
||||||
const [voting, setVoting] = useState(false);
|
const [voting, setVoting] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// dışarıdan gelen score güncellenirse state'i de güncelle
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentScore(score);
|
setCurrentScore(score)
|
||||||
}, [score]);
|
}, [score])
|
||||||
|
|
||||||
const handleVote = async (type: "UP" | "DOWN") => {
|
const handleVote = async (e: React.MouseEvent, type: "UP" | "DOWN") => {
|
||||||
const token = localStorage.getItem("token");
|
e.stopPropagation()
|
||||||
|
const token = localStorage.getItem("token")
|
||||||
if (!token) {
|
if (!token) {
|
||||||
alert("Giriş yapmalısın");
|
alert("Giriş yapmalısın")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setVoting(true);
|
setVoting(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:3000/api/deal-votes", {
|
const data = await voteDeal(token, id, type)
|
||||||
method: "POST",
|
if (typeof data.score === "number") setCurrentScore(data.score)
|
||||||
headers: {
|
else alert(data.error || "Oy gönderilemedi")
|
||||||
"Content-Type": "application/json",
|
} catch {
|
||||||
Authorization: `Bearer ${token}`,
|
alert("Sunucu hatası")
|
||||||
},
|
|
||||||
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ı");
|
|
||||||
} finally {
|
} finally {
|
||||||
setVoting(false);
|
setVoting(false)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const handleCardClick = () => {
|
||||||
|
navigate(`/deal/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="w-42 h-42 flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
|
|
@ -79,7 +76,7 @@ export default function DealCardMain({
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
disabled={voting}
|
disabled={voting}
|
||||||
onClick={() => handleVote("UP")}
|
onClick={(e) => handleVote(e, "UP")}
|
||||||
className="text-green-600 font-bold text-lg"
|
className="text-green-600 font-bold text-lg"
|
||||||
>
|
>
|
||||||
▲
|
▲
|
||||||
|
|
@ -87,16 +84,16 @@ export default function DealCardMain({
|
||||||
<span>{currentScore ?? 0}°</span>
|
<span>{currentScore ?? 0}°</span>
|
||||||
<button
|
<button
|
||||||
disabled={voting}
|
disabled={voting}
|
||||||
onClick={() => handleVote("DOWN")}
|
onClick={(e) => handleVote(e, "DOWN")}
|
||||||
className="text-red-600 font-bold text-lg"
|
className="text-red-600 font-bold text-lg"
|
||||||
>
|
>
|
||||||
▼
|
▼
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
<span>Posted {postedAgo}</span>
|
<span> {postedAgo} </span>
|
||||||
</div>
|
</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}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -115,11 +112,14 @@ export default function DealCardMain({
|
||||||
<span>💬 {comments}</span>
|
<span>💬 {comments}</span>
|
||||||
<span>🔗 Paylaş</span>
|
<span>🔗 Paylaş</span>
|
||||||
</div>
|
</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
|
Fırsatı kap
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 { BrowserRouter, Routes, Route } from "react-router-dom"; // <-- Burası önemli
|
||||||
import HomePage from "./pages/HomePage";
|
import HomePage from "./pages/HomePage";
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
|
import DealPage from "./pages/DealPage";
|
||||||
|
import SubmitDealPage from "./pages/SubmitDealPage";
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/deal/:id" element={<DealPage />} />
|
||||||
|
<Route path="/submit-deal" element={<SubmitDealPage />} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</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 { useEffect, useState, useRef } from "react"
|
||||||
import MainLayout from "../layouts/MainLayout";
|
import MainLayout from "../layouts/MainLayout"
|
||||||
import HomeLayout from "../layouts/HomeLayout";
|
import HomeLayout from "../layouts/HomeLayout"
|
||||||
import DealCardMain from "../components/Shared/DealCardMain";
|
import DealCardMain from "../components/Shared/DealCardMain"
|
||||||
|
import { getDeals } from "../api/deal/getDeal"
|
||||||
|
import { timeAgo } from "../utils/timeAgo"
|
||||||
|
|
||||||
type Deal = {
|
type Deal = {
|
||||||
id: number;
|
id: number
|
||||||
title: string;
|
title: string
|
||||||
description: string;
|
description: string
|
||||||
url: string;
|
url: string
|
||||||
imageUrl: string;
|
imageUrl: string
|
||||||
price: number;
|
price: number
|
||||||
score:number;
|
score: number
|
||||||
createdAt: string;
|
createdAt: string
|
||||||
user?: { username: string };
|
user?: { username: string }
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
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(() => {
|
useEffect(() => {
|
||||||
async function fetchDeals() {
|
const loadDeals = async () => {
|
||||||
|
if (loading || !hasMore) return
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch("http://localhost:3000/api/deals");
|
const newDeals = await getDeals(page)
|
||||||
const data = await res.json();
|
if (newDeals.length === 0) {
|
||||||
setDeals(data);
|
setHasMore(false)
|
||||||
} catch (err) {
|
} else {
|
||||||
console.error("Error fetching deals:", err);
|
// 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 (
|
return (
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<HomeLayout>
|
<HomeLayout>
|
||||||
{deals.map((deal) => (
|
{deals.map((deal) => (
|
||||||
<DealCardMain
|
<DealCardMain
|
||||||
key={deal.id}
|
key={deal.id}
|
||||||
id={deal.id} // <-- eksik olan bu satır
|
id={deal.id}
|
||||||
image={deal.imageUrl}
|
image={deal.imageUrl}
|
||||||
title={deal.title}
|
title={deal.title}
|
||||||
price={`${deal.price}₺`}
|
price={`${deal.price}₺`}
|
||||||
store={new URL(deal.url).hostname}
|
store={deal.url ? new URL(deal.url).hostname : "Bilinmiyor"}
|
||||||
postedBy={deal.user?.username || "unknown"}
|
postedBy={deal.user?.username || "unknown"}
|
||||||
score={deal.score}
|
score={deal.score}
|
||||||
comments={0}
|
comments={0}
|
||||||
postedAgo={new Date(deal.createdAt).toLocaleDateString("tr-TR")}
|
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>
|
</HomeLayout>
|
||||||
</MainLayout>
|
</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