From c577e89218227ec5dfb2776d70460d52d07214c3 Mon Sep 17 00:00:00 2001 From: cureb Date: Mon, 3 Nov 2025 03:07:38 +0000 Subject: [PATCH] Add avatarUrl field to User schema and Supabase setup --- src/api/auth/login.ts | 14 ++ src/api/auth/register.ts | 14 ++ src/api/deal/commentDeal.ts | 24 ++++ src/api/deal/getDeal.ts | 22 +++ src/api/deal/newDeal.ts | 22 +++ src/api/deal/voteDeal.ts | 14 ++ src/components/DealScreen/DealComments.tsx | 109 ++++++++++++++ src/components/DealScreen/DealDescription.tsx | 16 +++ src/components/DealScreen/DealDetails.tsx | 44 ++++++ src/components/DealScreen/DealImages.tsx | 17 +++ src/components/Layout/Navbar/Navbar.tsx | 51 +++++-- src/components/Layout/Navbar/UserInfo.tsx | 133 ++++++++++-------- src/components/Shared/DealCardMain.tsx | 96 ++++++------- src/layouts/DealDetailsLayout.tsx | 31 ++++ src/main.tsx | 6 +- src/pages/DealPage.tsx | 62 ++++++++ src/pages/HomePage.tsx | 117 ++++++++++----- src/pages/SubmitDealPage.tsx | 90 ++++++++++++ src/utils/timeAgo.ts | 13 ++ 19 files changed, 736 insertions(+), 159 deletions(-) create mode 100644 src/api/auth/login.ts create mode 100644 src/api/auth/register.ts create mode 100644 src/api/deal/commentDeal.ts create mode 100644 src/api/deal/getDeal.ts create mode 100644 src/api/deal/newDeal.ts create mode 100644 src/api/deal/voteDeal.ts create mode 100644 src/components/DealScreen/DealComments.tsx create mode 100644 src/components/DealScreen/DealDescription.tsx create mode 100644 src/components/DealScreen/DealDetails.tsx create mode 100644 src/components/DealScreen/DealImages.tsx create mode 100644 src/layouts/DealDetailsLayout.tsx create mode 100644 src/pages/DealPage.tsx create mode 100644 src/pages/SubmitDealPage.tsx create mode 100644 src/utils/timeAgo.ts diff --git a/src/api/auth/login.ts b/src/api/auth/login.ts new file mode 100644 index 0000000..713ab42 --- /dev/null +++ b/src/api/auth/login.ts @@ -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 } +} diff --git a/src/api/auth/register.ts b/src/api/auth/register.ts new file mode 100644 index 0000000..fb75b4e --- /dev/null +++ b/src/api/auth/register.ts @@ -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 } +} diff --git a/src/api/deal/commentDeal.ts b/src/api/deal/commentDeal.ts new file mode 100644 index 0000000..7ac3f52 --- /dev/null +++ b/src/api/deal/commentDeal.ts @@ -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 +} diff --git a/src/api/deal/getDeal.ts b/src/api/deal/getDeal.ts new file mode 100644 index 0000000..cd2043e --- /dev/null +++ b/src/api/deal/getDeal.ts @@ -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() +} \ No newline at end of file diff --git a/src/api/deal/newDeal.ts b/src/api/deal/newDeal.ts new file mode 100644 index 0000000..b103c9b --- /dev/null +++ b/src/api/deal/newDeal.ts @@ -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 +} diff --git a/src/api/deal/voteDeal.ts b/src/api/deal/voteDeal.ts new file mode 100644 index 0000000..ec589a7 --- /dev/null +++ b/src/api/deal/voteDeal.ts @@ -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() +} diff --git a/src/components/DealScreen/DealComments.tsx b/src/components/DealScreen/DealComments.tsx new file mode 100644 index 0000000..4198318 --- /dev/null +++ b/src/components/DealScreen/DealComments.tsx @@ -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([]) + 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 ( +
+

Yorumlar

+ +
    + {comments.length > 0 ? ( + comments.map((c) => ( +
  • +

    + {c.user.username}: {c.text} +

    +

    + {new Date(c.createdAt).toLocaleString("tr-TR")} +

    +
  • + )) + ) : ( +

    Henüz yorum yok.

    + )} +
+ + {token ? ( +
+ setNewComment(e.target.value)} + placeholder="Yorum ekle..." + className="flex-1 border rounded-md px-3 py-2 text-sm" + disabled={loading} + /> + +
+ ) : ( +

+ Yorum yazmak için{" "} + + giriş yap + {" "} + veya{" "} + + kayıt ol + + . +

+ )} +
+ ) +} diff --git a/src/components/DealScreen/DealDescription.tsx b/src/components/DealScreen/DealDescription.tsx new file mode 100644 index 0000000..ce986a5 --- /dev/null +++ b/src/components/DealScreen/DealDescription.tsx @@ -0,0 +1,16 @@ +import React from "react" + +type DealDescriptionProps = { + description: string +} + +export default function DealDescription({ description }: DealDescriptionProps) { + return ( +
+

Açıklama

+

+ {description} +

+
+ ) +} diff --git a/src/components/DealScreen/DealDetails.tsx b/src/components/DealScreen/DealDetails.tsx new file mode 100644 index 0000000..5f2d126 --- /dev/null +++ b/src/components/DealScreen/DealDetails.tsx @@ -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 ( +
+

{title}

+ +
{price}
+ +
+ Mağaza: {store} +
+ + + Anlaşmayı Gör + + +
+ Paylaşan: {postedBy} • {postedAgo} önce +
+
+ ) +} diff --git a/src/components/DealScreen/DealImages.tsx b/src/components/DealScreen/DealImages.tsx new file mode 100644 index 0000000..44baec0 --- /dev/null +++ b/src/components/DealScreen/DealImages.tsx @@ -0,0 +1,17 @@ + +type DealImagesProps = { + imageUrl: string + alt?: string +} + +export default function DealImages({ imageUrl, alt }: DealImagesProps) { + return ( +
+ {alt +
+ ) +} diff --git a/src/components/Layout/Navbar/Navbar.tsx b/src/components/Layout/Navbar/Navbar.tsx index 1420407..ed7490e 100644 --- a/src/components/Layout/Navbar/Navbar.tsx +++ b/src/components/Layout/Navbar/Navbar.tsx @@ -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 - ); + ) } diff --git a/src/components/Layout/Navbar/UserInfo.tsx b/src/components/Layout/Navbar/UserInfo.tsx index dedd7de..9b27976 100644 --- a/src/components/Layout/Navbar/UserInfo.tsx +++ b/src/components/Layout/Navbar/UserInfo.tsx @@ -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(null); - const [showModal, setShowModal] = useState(false); + const [user, setUser] = useState(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 ( -
+
{user ? ( -
-
- {user.username} - {user.username} -
- +
+ {user.username} setMenuOpen((prev) => !prev)} + /> + + {menuOpen && ( +
+

{user.username}

+ + +
+ )}
) : (
- ); + ) } diff --git a/src/components/Shared/DealCardMain.tsx b/src/components/Shared/DealCardMain.tsx index dcf3c46..6645353 100644 --- a/src/components/Shared/DealCardMain.tsx +++ b/src/components/Shared/DealCardMain.tsx @@ -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(score); - const [voting, setVoting] = useState(false); + const [currentScore, setCurrentScore] = useState(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 ( -
+
- Posted {postedAgo} + {postedAgo}
-

+

{title}

@@ -115,11 +112,14 @@ export default function DealCardMain({ 💬 {comments} 🔗 Paylaş
-
- ); + ) } diff --git a/src/layouts/DealDetailsLayout.tsx b/src/layouts/DealDetailsLayout.tsx new file mode 100644 index 0000000..6b3f1e1 --- /dev/null +++ b/src/layouts/DealDetailsLayout.tsx @@ -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 ( +
+ {/* Üst kısım: sol görsel + sağ detay */} +
+ {Image} +
+ +
+ {Details} +
+ + {/* Alt kısım: açıklama ve yorumlar tam genişlikte */} +
+
{Description}
+
{Comments}
+
+
+) + +} diff --git a/src/main.tsx b/src/main.tsx index 7f151d4..e389571 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( } /> + } /> + } /> + diff --git a/src/pages/DealPage.tsx b/src/pages/DealPage.tsx new file mode 100644 index 0000000..8d5c60f --- /dev/null +++ b/src/pages/DealPage.tsx @@ -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(null) + + useEffect(() => { + if (!id) return + getDeal(Number(id)) + .then(setDeal) + .catch((err) => console.error(err)) + }, [id]) + + if (!deal) return

Yükleniyor...

+ + return ( + + } + Details={ + + } + Description={} + Comments={} + /> + + ) +} diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 98744d9..c9121c6 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -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([]); + const [deals, setDeals] = useState([]) + const [page, setPage] = useState(1) + const [hasMore, setHasMore] = useState(true) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const observerRef = useRef(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

{error}

return ( {deals.map((deal) => ( - - + ))} + {loading &&

Yükleniyor...

} + {!hasMore && ( +

+ Tüm fırsatlar yüklendi. +

+ )} +
- ); + ) } diff --git a/src/pages/SubmitDealPage.tsx b/src/pages/SubmitDealPage.tsx new file mode 100644 index 0000000..a3c0054 --- /dev/null +++ b/src/pages/SubmitDealPage.tsx @@ -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 ( + +
+

Fırsat Yolla

+
+ setTitle(e.target.value)} + className="border rounded-md px-3 py-2" + required + /> +