diff --git a/app/app/api/cron/expire-posts/route.ts b/app/app/api/cron/expire-posts/route.ts new file mode 100644 index 0000000..d9c062a --- /dev/null +++ b/app/app/api/cron/expire-posts/route.ts @@ -0,0 +1,66 @@ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { createNotification } from "@/lib/notifications"; +import { prisma } from "@/lib/prisma"; +import { NextRequest } from "next/server"; + +function isAuthorizedCron(request: NextRequest): boolean { + if (request.headers.get("x-vercel-cron") === "1") { + return true; + } + const secret = process.env.CRON_SECRET; + if (!secret) { + return false; + } + const auth = request.headers.get("authorization"); + return auth === `Bearer ${secret}`; +} + +/** + * Hourly job: mark open giveaways whose deadline has passed as expired, + * and notify creators. + */ +const GET = async (request: NextRequest) => { + if (!isAuthorizedCron(request)) { + return apiError("Unauthorized", 401); + } + + const now = new Date(); + + const toExpire = await prisma.post.findMany({ + where: { + status: "open", + endsAt: { lt: now }, + }, + select: { id: true, userId: true, title: true }, + }); + + if (toExpire.length === 0) { + return apiSuccess({ expired: 0, ids: [] as string[] }); + } + + await prisma.post.updateMany({ + where: { + status: "open", + endsAt: { lt: now }, + }, + data: { status: "expired" }, + }); + + await Promise.all( + toExpire.map((post) => + createNotification({ + userId: post.userId, + type: "post_closed", + message: `Your giveaway "${post.title}" has ended (deadline passed).`, + link: `/post/${post.id}`, + }).catch(() => undefined), + ), + ); + + return apiSuccess({ + expired: toExpire.length, + ids: toExpire.map((p) => p.id), + }); +}; + +export { GET }; diff --git a/app/app/api/posts/route.ts b/app/app/api/posts/route.ts index 6b64873..720342d 100644 --- a/app/app/api/posts/route.ts +++ b/app/app/api/posts/route.ts @@ -201,11 +201,16 @@ const GET = async (request: NextRequest) => { const q = searchParams.get("q"); const category = searchParams.get("category"); const sort = searchParams.get("sort"); + const filter = searchParams.get("filter"); const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "10"); const where: any = {}; + if (filter === "active") { + where.endsAt = { gt: new Date() }; + } + if (q) { where.OR = [ { title: { contains: q, mode: "insensitive" } }, diff --git a/app/app/post/[postId]/page.tsx b/app/app/post/[postId]/page.tsx index 5488dda..d82d231 100644 --- a/app/app/post/[postId]/page.tsx +++ b/app/app/post/[postId]/page.tsx @@ -7,7 +7,6 @@ import { ChevronLeft, ChevronRight, ChevronUp, - Clock, DollarSign, Flame, Gift, @@ -24,15 +23,16 @@ import { Button } from "@/components/ui/button"; import { CommentsSection } from "@/components/comments-section"; import { ContributionForm } from "@/components/contribution-form"; import { EntryForm } from "@/components/entry-form"; +import { GiveawayCountdown } from "@/components/giveaway-countdown"; import Link from "next/link"; import { Progress } from "@/components/ui/progress"; import { UserRankBadge } from "@/components/user-rank-badge"; import { useAppContext } from "@/contexts/app-context"; -import { useEffect, useState } from "react"; +import { mapApiPostToClientPost } from "@/lib/map-api-post"; +import { useCallback, useEffect, useState } from "react"; export default function PostPage() { - const { user, burnPost } = useAppContext(); - const [post, setPost] = useState(null); + const { user, burnPost, posts, refreshPostDetail } = useAppContext(); const params = useParams(); const postId = params.postId as string; const contextPost = posts.find((p) => p.id === postId); @@ -47,79 +47,65 @@ export default function PostPage() { const [showContributionForm, setShowContributionForm] = useState(false); const router = useRouter(); - const normalizeApiPost = (apiPost: any) => { - if (!apiPost) return null; - - const normalizedType = apiPost.type; - const normalizedStatus = - apiPost.status === "open" || apiPost.status === "in_progress" - ? "active" - : apiPost.status; - const fallbackUsername = - apiPost.user?.name?.toLowerCase()?.replace(/\s+/g, "") || "user"; - - return { - ...apiPost, - type: normalizedType, - status: normalizedStatus, - createdAt: apiPost.createdAt ? new Date(apiPost.createdAt) : new Date(), - updatedAt: apiPost.updatedAt ? new Date(apiPost.updatedAt) : new Date(), - endDate: apiPost.endsAt ? new Date(apiPost.endsAt) : undefined, - burnCount: apiPost.burnCount ?? 0, - shareCount: apiPost.shareCount ?? 0, - commentCount: apiPost.commentCount ?? 0, - likesCount: apiPost.likesCount ?? apiPost._count?.interactions ?? 0, - entriesCount: apiPost.entriesCount ?? apiPost._count?.entries ?? 0, - author: { - id: apiPost.user?.id ?? apiPost.userId, - name: apiPost.user?.name ?? "Unknown User", - username: apiPost.user?.username ?? fallbackUsername, - avatarUrl: apiPost.user?.avatarUrl, - rank: apiPost.user?.rank, - }, - }; - }; - useEffect(() => { - let ignore = false; + const reloadPostFromApi = useCallback(async () => { + setIsLoadingPost(true); + try { + const response = await fetch(`/api/posts/${postId}`, { + cache: "no-store", + }); - const loadPost = async () => { - if (contextPost) { + if (!response.ok) { setFetchedPost(null); - setIsLoadingPost(false); return; } - setIsLoadingPost(true); + const result = await response.json(); + setFetchedPost( + mapApiPostToClientPost(result?.data as Record), + ); + } catch { + setFetchedPost(null); + } finally { + setIsLoadingPost(false); + } + }, [postId]); + useEffect(() => { + if (contextPost) { + setFetchedPost(null); + setIsLoadingPost(false); + return; + } + + let ignore = false; + + const run = async () => { + setIsLoadingPost(true); try { const response = await fetch(`/api/posts/${postId}`, { cache: "no-store", }); if (!response.ok) { - if (!ignore) { - setFetchedPost(null); - } + if (!ignore) setFetchedPost(null); return; } const result = await response.json(); if (!ignore) { - setFetchedPost(normalizeApiPost(result?.data)); - } - } catch (error) { - if (!ignore) { - setFetchedPost(null); + setFetchedPost( + mapApiPostToClientPost(result?.data as Record), + ); } + } catch { + if (!ignore) setFetchedPost(null); } finally { - if (!ignore) { - setIsLoadingPost(false); - } + if (!ignore) setIsLoadingPost(false); } }; - loadPost(); + void run(); return () => { ignore = true; @@ -142,7 +128,8 @@ export default function PostPage() { body: JSON.stringify({ method, winnerIds }) }); if (res.ok) { - loadPost(); + await refreshPostDetail(post.id); + if (!contextPost) await reloadPostFromApi(); } else { const errorData = await res.json(); alert(errorData.error || 'Failed to select winners'); @@ -418,10 +405,7 @@ export default function PostPage() { {post.endDate && ( -
- - Ends {post.endDate.toLocaleDateString()} -
+ )} diff --git a/app/components/create-giveaway-modal.tsx b/app/components/create-giveaway-modal.tsx index e32867f..5d2cb52 100644 --- a/app/components/create-giveaway-modal.tsx +++ b/app/components/create-giveaway-modal.tsx @@ -93,14 +93,17 @@ const response = await fetch("/api/posts", { type: "giveaway", title: formData.title, description: formData.description, - status: "active", prizeAmount: Number.parseFloat(formData.prizeAmount), currency: formData.currency, winnerCount: Number.parseInt(formData.winnerCount), selectionType: formData.selectionType, entryRequirements: requirements, proofRequired: formData.proofRequired, - endDate: formData.endDate ? new Date(formData.endDate) : undefined, + endsAt: ( + formData.endDate + ? new Date(formData.endDate) + : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) + ).toISOString(), media: media.length > 0 ? media : undefined, }), }); diff --git a/app/components/giveaway-countdown.tsx b/app/components/giveaway-countdown.tsx new file mode 100644 index 0000000..35c4d32 --- /dev/null +++ b/app/components/giveaway-countdown.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Clock } from "lucide-react"; +import { useEffect, useState } from "react"; + +function formatRemaining(ms: number): string { + if (ms <= 0) return "0s"; + const s = Math.floor(ms / 1000); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const parts: string[] = []; + if (d > 0) parts.push(`${d}d`); + if (h > 0 || d > 0) parts.push(`${h}h`); + if (m > 0 || h > 0 || d > 0) parts.push(`${m}m`); + parts.push(`${sec}s`); + return parts.join(" "); +} + +type GiveawayCountdownProps = { + endsAt: Date; + className?: string; + showIcon?: boolean; +}; + +export function GiveawayCountdown({ + endsAt, + className = "", + showIcon = true, +}: GiveawayCountdownProps) { + const [label, setLabel] = useState(() => { + const diff = endsAt.getTime() - Date.now(); + return diff <= 0 ? "Ended" : formatRemaining(diff); + }); + + useEffect(() => { + const tick = () => { + const diff = endsAt.getTime() - Date.now(); + setLabel(diff <= 0 ? "Ended" : formatRemaining(diff)); + }; + tick(); + const id = window.setInterval(tick, 1000); + return () => window.clearInterval(id); + }, [endsAt]); + + const ended = endsAt.getTime() <= Date.now(); + + return ( +
+ {showIcon && } + {ended ? "Ended" : `${label} left`} +
+ ); +} diff --git a/app/components/post-card.tsx b/app/components/post-card.tsx index 824f5c3..11d1b5a 100644 --- a/app/components/post-card.tsx +++ b/app/components/post-card.tsx @@ -5,7 +5,6 @@ import { Calendar, ChevronDown, ChevronUp, - Clock, DollarSign, Flame, Gift, @@ -21,6 +20,7 @@ import { Button } from '@/components/ui/button'; import { CommentsSection } from '@/components/comments-section'; import { ContributionForm } from '@/components/contribution-form'; import { EntryForm } from '@/components/entry-form'; +import { GiveawayCountdown } from '@/components/giveaway-countdown'; import Link from 'next/link'; import type { Post } from '@/lib/types'; import { Progress } from '@/components/ui/progress'; @@ -125,6 +125,14 @@ export function PostCard({ post }: PostCardProps) { } }; + const giveawayDeadline = + post.type === 'giveaway' && post.endDate ? post.endDate : null; + const giveawayStillOpen = + post.type === 'giveaway' && + (post.status === 'active' || post.status === 'open') && + giveawayDeadline && + giveawayDeadline.getTime() > Date.now(); + const getProgressPercentage = () => { if ( post.type === 'request' && @@ -293,11 +301,8 @@ export function PostCard({ post }: PostCardProps) { Prize: {post.prizeAmount} {post.currency} - {post.endDate && ( -
- - Ends {post.endDate.toLocaleDateString()} -
+ {giveawayDeadline && ( + )} @@ -317,7 +322,7 @@ export function PostCard({ post }: PostCardProps) { )} - {post.status === 'active' && ( + {giveawayStillOpen && (