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
28 changes: 27 additions & 1 deletion app/app/api/users/[id]/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,30 @@ export async function GET (
return apiError('User not found', 404);
}

// Fetch user's posts
// Fetch user's posts (shape aligned with feed/detail for PostCard)
const userPosts = await prisma.post.findMany({
where: { userId: id },
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
name: true,
avatarUrl: true,
username: true,
rank: {
select: {
id: true,
level: true,
title: true,
color: true,
minPoints: true,
maxPoints: true,
},
},
},
},
media: true,
entries: {
select: {
id: true,
Expand All @@ -36,6 +55,13 @@ export async function GET (
createdAt: true,
},
},
_count: {
select: {
entries: true,
interactions: true,
comments: true,
},
},
},
});

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
22 changes: 16 additions & 6 deletions app/app/profile/[userId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ import { FollowListDialog } from '@/components/follow-list-dialog';
import { PostCard } from '@/components/post-card';
import { UserRankBadge } from '@/components/user-rank-badge';
import { useAppContext } from '@/contexts/app-context';
import { mapApiPostToClientPost } from '@/lib/map-api-post';
import type { Post } from '@/lib/types';
import { useParams } from 'next/navigation';
import { useState, useEffect } from 'react';

export default function ProfilePage() {
const params = useParams();
const { user: currentUser } = useAppContext();
const [profileUser, setProfileUser] = useState<any>(null);
const [userPosts, setUserPosts] = useState<any[]>([]);
const [userPosts, setUserPosts] = useState<Post[]>([]);
const [showAchievements, setShowAchievements] = useState(false);
const [isFollowing, setIsFollowing] = useState(false);
const [followerCount, setFollowerCount] = useState(0);
Expand All @@ -42,8 +44,8 @@ export default function ProfilePage() {
const loadProfile = async () => {
try {
const [userRes, postsRes] = await Promise.all([
fetch(`/api/users/${userId}`),
fetch(`/api/posts?userId=${userId}`),
fetch(`/api/users/${userId}`, { cache: 'no-store' }),
fetch(`/api/users/${userId}/posts`, { cache: 'no-store' }),
]);

if (userRes.ok) {
Expand All @@ -55,8 +57,14 @@ export default function ProfilePage() {
}

if (postsRes.ok) {
const postsData = await postsRes.json();
setUserPosts(postsData.data ?? []);
const postsData = await postsRes.json();
const rawList = Array.isArray(postsData.data) ? postsData.data : [];
const mapped = rawList
.map((p: Record<string, unknown>) => mapApiPostToClientPost(p))
.filter(Boolean) as Post[];
setUserPosts(mapped);
} else {
setUserPosts([]);
}
} catch (error) {
console.error('Failed to load profile:', error);
Expand All @@ -82,7 +90,9 @@ export default function ProfilePage() {
setIsFollowing(!newIsFollowing);
setFollowerCount((prev) => (newIsFollowing ? prev - 1 : prev + 1));
}
}; if (!profileUser) {
};

if (!profileUser) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="text-center">
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
Loading
Loading