Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions app/app/api/cron/expire-posts/route.ts
Original file line number Diff line number Diff line change
@@ -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 };
5 changes: 5 additions & 0 deletions app/app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
Expand Down
102 changes: 43 additions & 59 deletions app/app/post/[postId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
DollarSign,
Flame,
Gift,
Expand All @@ -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<any>(null);
const { user, burnPost, posts, refreshPostDetail } = useAppContext();
const params = useParams();
const postId = params.postId as string;
const contextPost = posts.find((p) => p.id === postId);
Expand All @@ -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<string, unknown>),
);
} 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<string, unknown>),
);
}
} catch {
if (!ignore) setFetchedPost(null);
} finally {
if (!ignore) {
setIsLoadingPost(false);
}
if (!ignore) setIsLoadingPost(false);
}
};

loadPost();
void run();

return () => {
ignore = true;
Expand All @@ -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');
Expand Down Expand Up @@ -418,10 +405,7 @@ export default function PostPage() {
</span>
</div>
{post.endDate && (
<div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
Ends {post.endDate.toLocaleDateString()}
</div>
<GiveawayCountdown endsAt={post.endDate} />
)}
</div>

Expand Down
7 changes: 5 additions & 2 deletions app/components/create-giveaway-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
});
Expand Down
61 changes: 61 additions & 0 deletions app/components/giveaway-countdown.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
className={`flex items-center gap-1.5 text-sm font-medium tabular-nums ${
ended
? "text-gray-500 dark:text-gray-400"
: "text-amber-700 dark:text-amber-400"
} ${className}`}
>
{showIcon && <Clock className="w-4 h-4 shrink-0" />}
<span>{ended ? "Ended" : `${label} left`}</span>
</div>
);
}
19 changes: 12 additions & 7 deletions app/components/post-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
Calendar,
ChevronDown,
ChevronUp,
Clock,
DollarSign,
Flame,
Gift,
Expand All @@ -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';
Expand Down Expand Up @@ -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' &&
Expand Down Expand Up @@ -293,11 +301,8 @@ export function PostCard({ post }: PostCardProps) {
Prize: {post.prizeAmount} {post.currency}
</span>
</div>
{post.endDate && (
<div className="flex items-center gap-1 text-sm text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
Ends {post.endDate.toLocaleDateString()}
</div>
{giveawayDeadline && (
<GiveawayCountdown endsAt={giveawayDeadline} />
)}
</div>

Expand All @@ -317,7 +322,7 @@ export function PostCard({ post }: PostCardProps) {
</div>
)}

{post.status === 'active' && (
{giveawayStillOpen && (
<Button
onClick={(e) => {
handleInteractiveClick(e);
Expand Down
Loading
Loading