From f91505d996c4f6d5d9c34dc6bc63e341031d20ba Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Thu, 23 Oct 2025 17:44:10 +0800 Subject: [PATCH 01/10] display social points in dashboard --- .../_components/DashboardTokenPoints.tsx | 256 ++++++++++++------ 1 file changed, 174 insertions(+), 82 deletions(-) diff --git a/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx b/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx index feee10642..9d2bb20d7 100644 --- a/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx +++ b/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx @@ -9,13 +9,23 @@ import { Skeleton } from "@/components/ui/skeleton/skeleton"; import { useUserStore } from "@/store/codev-store"; import { CodevPoints, Level, SkillCategory } from "@/types/home/codev"; import { createClientClientComponent } from "@/utils/supabase/client"; -import { Zap, Target, TrendingUp, Award, Star, ArrowUp, Calendar, UserRoundPen } from "lucide-react"; +import { + ArrowUp, + Award, + Calendar, + Star, + Target, + TrendingUp, + UserRoundPen, + Zap, +} from "lucide-react"; export default function TokenPoints() { const { user } = useUserStore(); const [points, setPoints] = useState>({}); const [levels, setLevels] = useState>({}); const [attendancePoints, setAttendancePoints] = useState(0); + const [socialPoints, setSocialPoints] = useState(0); const [profilePoints, setProfilePoints] = useState(0); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -26,15 +36,18 @@ export default function TokenPoints() { useEffect(() => { let isMounted = true; - const supabase = createClientClientComponent(); + const supabase = createClientClientComponent()!; const fetchPointsAndCategories = async () => { setLoading(true); setError(null); // FIXED: Use getUser() instead of getSession() - const { data: { user: authUser }, error: authError } = await supabase.auth.getUser(); - + const { + data: { user: authUser }, + error: authError, + } = await supabase.auth.getUser(); + if (!isMounted) return; if (authError || !authUser) { @@ -55,8 +68,9 @@ export default function TokenPoints() { if (categoriesError) throw categoriesError; - const skillCategories = (categories as SkillCategory[]) - .filter(category => category.name !== "Project Manager"); // Exclude PM from points overview + const skillCategories = (categories as SkillCategory[]).filter( + (category) => category.name !== "Project Manager", + ); // Exclude PM from points overview // Fetch user's points const { data: pointsData, error: pointsError } = await supabase @@ -94,16 +108,20 @@ export default function TokenPoints() { .single(); // If no attendance points record exists, create one - if (attendanceError && attendanceError.code === 'PGRST116') { + if (attendanceError && attendanceError.code === "PGRST116") { // Count actual attendance records - const { data: attendanceCount, error: countError, count } = await supabase + const { + data: attendanceCount, + error: countError, + count, + } = await supabase .from("attendance") - .select("*", { count: 'exact', head: true }) + .select("*", { count: "exact", head: true }) .eq("codev_id", authUser.id) .in("status", ["present", "late"]); - + const totalPoints = (count || 0) * 2; - + if (totalPoints > 0) { // Create attendance points record const { data: newAttendancePoints } = await supabase @@ -111,11 +129,11 @@ export default function TokenPoints() { .insert({ codev_id: authUser.id, points: totalPoints, - last_updated: new Date().toISOString().split('T')[0] + last_updated: new Date().toISOString().split("T")[0], }) .select() .single(); - + if (isMounted) { setAttendancePoints(newAttendancePoints?.points || 0); } @@ -140,7 +158,9 @@ export default function TokenPoints() { try { const res = await fetch(`/api/profile-points/${user.id}`); if (res.ok) { - const data = (await res.json()) as { success?: boolean; totalPoints?: number } | undefined; + const data = (await res.json()) as + | { success?: boolean; totalPoints?: number } + | undefined; if (data?.success && isMounted) { setProfilePoints(data.totalPoints ?? 0); } @@ -153,6 +173,24 @@ export default function TokenPoints() { } } + // Fetch social points + if (user?.id) { + try { + // Fetch user's points + const { data: pointsData, error: pointsError } = await supabase.rpc( + "calculate_social_points", + { codev_id: user.id }, + ); + + if (pointsError) throw pointsError; + if (isMounted) { + setSocialPoints(pointsData); + } + } catch (error) { + console.error("Failed to fetch social points:", error); + } + } + // Fetch levels for all categories const { data: levelsData, error: levelsError } = await supabase .from("levels") @@ -238,29 +276,29 @@ export default function TokenPoints() { if (loading) { return ( - + {/* Background decoration */} -
-
- +
+
+
{/* Header skeleton */} -
+
- + {/* Cards skeleton */} -
+
{[1, 2, 3, 4, 5, 6].map((i) => (
-
+
@@ -268,9 +306,9 @@ export default function TokenPoints() {
-
- - +
+ +
@@ -298,7 +336,7 @@ export default function TokenPoints() { "Backend Developer": , "UI/UX Designer": , "Mobile Developer": , - "QA Engineer": + "QA Engineer": , }; return iconMap[category] || ; }; @@ -306,61 +344,67 @@ export default function TokenPoints() { const getCategoryColor = (category: string) => { const colorMap: Record = { "Frontend Developer": "from-customBlue-500 to-cyan-500", - "Backend Developer": "from-green-500 to-emerald-500", + "Backend Developer": "from-green-500 to-emerald-500", "UI/UX Designer": "from-purple-500 to-pink-500", "Mobile Developer": "from-orange-500 to-red-500", - "QA Engineer": "from-indigo-500 to-customBlue-500" + "QA Engineer": "from-indigo-500 to-customBlue-500", }; return colorMap[category] || "from-gray-500 to-gray-600"; }; const getProgressToNextLevel = (category: string, currentPoints: number) => { const nextLevelThreshold = (levels[category] ?? 1) * 100; // Assuming 100 points per level, default to level 1 if undefined - const progress = (currentPoints % 100) / 100 * 100; + const progress = ((currentPoints % 100) / 100) * 100; return Math.min(progress, 100); }; - const totalSkillPoints = Object.values(points).reduce((sum, point) => sum + point, 0); + const totalSkillPoints = Object.values(points).reduce( + (sum, point) => sum + point, + 0, + ); const totalPoints = totalSkillPoints + attendancePoints + profilePoints; return ( - + {/* Background decoration */} -
-
- +
+
+
-
-
+
+
-

+

⚡ Points Overview

- Total: {totalPoints} points (Skills: {totalSkillPoints} + Attendance: {attendancePoints} + Profile: {profilePoints}) + Total: {totalPoints} points (Skills: {totalSkillPoints} + + Attendance: {attendancePoints} + Profile: {profilePoints})

- -
+ +
{/* Attendance Points Card */} -
-
+
+
-
+
-
+
-

Attendance

+

+ Attendance +

Consistency Points

-

+

{attendancePoints}

points

@@ -378,21 +422,23 @@ export default function TokenPoints() {
{/* Profile Points Card */} -
-
+
+
-
+
-
+
-
-

Profile Completion

+
+

+ Profile Completion +

Completion Points

-

+

{profilePoints}

points

@@ -400,7 +446,7 @@ export default function TokenPoints() {

- Earned by completing your profile information + Earned by completing your profile information

Points vary based on completeness @@ -409,56 +455,102 @@ export default function TokenPoints() {

+ {/* Social Points Card */} +
+
+
+
+
+
+ +
+
+

+ Social +

+

Engagement Points

+
+
+
+

+ {socialPoints} +

+

points

+
+
+
+

+ Earned by contributing posts to the community feed +

+

+ Extra points for received likes or comments. +

+
+
+
+ {/* Skill Points Cards */} {Object.entries(points).map(([category, point]) => { const currentLevel = levels[category] || 1; const progress = getProgressToNextLevel(category, point); - + return (
{/* Background gradient */} -
- +
+
-
+
-
+
{getCategoryIcon(category)}
-

{category}

-

Level {currentLevel}

+

+ {category} +

+

+ Level {currentLevel} +

-

+

{point}

points

- + {/* Progress bar for next level */}
-
- Progress to Level {currentLevel + 1} - {Math.round(progress)}% +
+ + Progress to Level {currentLevel + 1} + + + {Math.round(progress)}% +
-
-
+
- + {/* Level badge */} {currentLevel >= 2 && ( -
- +
+ LVL {currentLevel}
)} @@ -470,12 +562,12 @@ export default function TokenPoints() {
{roleToBePromoted == "Codev" && !promotionAccepted && (
-
-
+ -
-
+ ); -} \ No newline at end of file +} From 6b4362f3ec3486c9c789c1f1fb603d5fb13c262e Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Thu, 23 Oct 2025 17:44:56 +0800 Subject: [PATCH 02/10] fix sentence --- .../app/home/(dashboard)/_components/DashboardTokenPoints.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx b/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx index 9d2bb20d7..43bce8692 100644 --- a/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx +++ b/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx @@ -483,7 +483,7 @@ export default function TokenPoints() { Earned by contributing posts to the community feed

- Extra points for received likes or comments. + Extra points for received likes or comments

From d124eafed0d3a027252802d11ee1be266ef52dd6 Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Thu, 23 Oct 2025 18:00:39 +0800 Subject: [PATCH 03/10] change icon --- .../app/home/(dashboard)/_components/DashboardTokenPoints.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx b/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx index 43bce8692..e2c54c2fe 100644 --- a/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx +++ b/apps/codebility/app/home/(dashboard)/_components/DashboardTokenPoints.tsx @@ -13,6 +13,7 @@ import { ArrowUp, Award, Calendar, + MessageSquareText, Star, Target, TrendingUp, @@ -462,7 +463,7 @@ export default function TokenPoints() {
- +

From e3db6ce2e86ad83a8d34b83375f6ef8747e23021 Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Thu, 23 Oct 2025 18:21:53 +0800 Subject: [PATCH 04/10] display social points in feeds --- apps/codebility/app/home/feeds/page.tsx | 35 +++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/codebility/app/home/feeds/page.tsx b/apps/codebility/app/home/feeds/page.tsx index 37be53528..970243e81 100644 --- a/apps/codebility/app/home/feeds/page.tsx +++ b/apps/codebility/app/home/feeds/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import CreatePostModal from "@/components/modals/CreatePostModal"; import { Button } from "@/components/ui/button"; import { useUserStore } from "@/store/codev-store"; +import { MessageSquareText } from "lucide-react"; import Feed from "./_components/Feed"; import { getUserRole } from "./_services/action"; @@ -27,6 +28,7 @@ export default function FeedsPage() { const openModal = () => setIsModalOpen(true); const closeModal = () => setIsModalOpen(false); + return ( <>

@@ -37,9 +39,42 @@ export default function FeedsPage() { Codebility community.

+ {/* Social Points Card*/} +
+
+
+
+
+
+
+ +
+
+

+ Social Points +

+

Feed Engagement

+
+
+
+

+ {10} +

+

points

+
+
+
+

+ Earn more points by posting and getting likes and comments! +

+
+
+
+ +
From 26eb69583105f5a76d57f2419a8bc319f42548e9 Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Thu, 23 Oct 2025 18:25:51 +0800 Subject: [PATCH 05/10] change post card corner roundness --- apps/codebility/app/home/feeds/_components/Post.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/codebility/app/home/feeds/_components/Post.tsx b/apps/codebility/app/home/feeds/_components/Post.tsx index c2d54d875..f218af42d 100644 --- a/apps/codebility/app/home/feeds/_components/Post.tsx +++ b/apps/codebility/app/home/feeds/_components/Post.tsx @@ -60,7 +60,7 @@ export default function Post({ post, isAdmin, onDelete }: PostProps) { return (
{/* Delete Button in top-right */} From 06fc36fcd7f5548fe8b1e0c90f44863bf5fd15f8 Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Thu, 23 Oct 2025 18:41:21 +0800 Subject: [PATCH 06/10] connect social points display to db --- .../app/home/feeds/_services/action.ts | 19 +++++++++++++++++++ apps/codebility/app/home/feeds/page.tsx | 14 ++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/codebility/app/home/feeds/_services/action.ts b/apps/codebility/app/home/feeds/_services/action.ts index 431ec779d..f6f8d721a 100644 --- a/apps/codebility/app/home/feeds/_services/action.ts +++ b/apps/codebility/app/home/feeds/_services/action.ts @@ -409,3 +409,22 @@ export async function deletePostContentImages( throw error; } } + +export const getSocialPoints = async (userId: string): Promise => { + const supabase = await createClientServerComponent(); + + try { + // Fetch user's points + const { data , error} = await supabase.rpc( + "calculate_social_points", + { codev_id: userId}, + ); + + if (error) throw error; + + return data; + } catch (error) { + console.error("Failed to fetch social points:", error); + throw error; + } +}; \ No newline at end of file diff --git a/apps/codebility/app/home/feeds/page.tsx b/apps/codebility/app/home/feeds/page.tsx index 970243e81..79b9b6402 100644 --- a/apps/codebility/app/home/feeds/page.tsx +++ b/apps/codebility/app/home/feeds/page.tsx @@ -7,22 +7,28 @@ import { useUserStore } from "@/store/codev-store"; import { MessageSquareText } from "lucide-react"; import Feed from "./_components/Feed"; -import { getUserRole } from "./_services/action"; +import { getSocialPoints, getUserRole } from "./_services/action"; export default function FeedsPage() { const [isAdmin, setIsAdmin] = useState(true); const [isModalOpen, setIsModalOpen] = useState(false); + const [socialPoints, setSocialPoints] = useState(0); const { user } = useUserStore(); useEffect(() => { const fetchRole = async () => { - if (!user) return; - const role = await getUserRole(user.role_id ?? null); + const role = await getUserRole(user!.role_id ?? null); setIsAdmin(role === "Admin"); }; + const fetchSocialPoints = async () => { + const socialPoints = await getSocialPoints(user!.id); + setSocialPoints(socialPoints || 0); + }; + if (user) { fetchRole(); + fetchSocialPoints(); } }, [user]); @@ -58,7 +64,7 @@ export default function FeedsPage() {

- {10} + {socialPoints}

points

From ad037c825df7d767dec9775eb50f5ed03ef80a3e Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Fri, 24 Oct 2025 14:47:24 +0800 Subject: [PATCH 07/10] resolve conflicts --- .../app/home/feeds/_components/Post.tsx | 127 ------------------ 1 file changed, 127 deletions(-) delete mode 100644 apps/codebility/app/home/feeds/_components/Post.tsx diff --git a/apps/codebility/app/home/feeds/_components/Post.tsx b/apps/codebility/app/home/feeds/_components/Post.tsx deleted file mode 100644 index f218af42d..000000000 --- a/apps/codebility/app/home/feeds/_components/Post.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useEffect, useState } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import FeedPostModal from "@/components/modals/FeedPostModal"; -import { Box } from "@/components/shared/dashboard"; -import { defaultAvatar } from "@/public/assets/images"; -import { useUserStore } from "@/store/codev-store"; -import { format } from "date-fns"; -import { ArrowBigUp } from "lucide-react"; -import toast from "react-hot-toast"; - -import { deletePost } from "../_services/action"; -import { PostType } from "../_services/query"; -import PostUpvote from "./PostUpvote"; - -interface PostProps { - post: PostType; - isAdmin?: boolean; - onDelete?: (deletedPostId: string | number) => void; -} - -export default function Post({ post, isAdmin, onDelete }: PostProps) { - const [isModalOpen, setIsModalOpen] = useState(false); - const [isAuthor, setIsAuthor] = useState(false); - const { user } = useUserStore(); - - useEffect(() => { - const checkIfAuthor = async () => { - if (!user) return; - if (user.id === post.author_id?.id) { - setIsAuthor(true); - } else { - setIsAuthor(false); - } - }; - - if (user) { - checkIfAuthor(); - } - }, [user]); - - const handleDelete = async (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - try { - await deletePost(post.id); - toast.success("Post deleted successfully!"); - - if (onDelete) { - onDelete(post.id); - } - } catch (error) { - toast.error("Failed to delete post."); - console.error("Failed to delete post:", error); - } - }; - - const openModal = () => setIsModalOpen(true); - const closeModal = () => setIsModalOpen(false); - return ( -
- - {/* Delete Button in top-right */} - {(isAdmin || isAuthor) && ( - - )} - -
-
-
- {`${post.author_id?.first_name}'s -
-

- {post.author_id - ? `${post.author_id?.first_name} ${post.author_id?.last_name}` - : "Codebility"} -

-
-
-
- -
-

- {post.title} -

-
- -

- {format(new Date(post.created_at), "MMM d, yyyy")} -

-
- -
- Post image -
- -
- -
-
- -
- ); -} From 571c01612420b703ff2906ca47fb1a7987740452 Mon Sep 17 00:00:00 2001 From: JM-Flores Date: Fri, 24 Oct 2025 14:56:47 +0800 Subject: [PATCH 08/10] resolve conflicts --- .../_components/CodevsOrbitingCirclesBg.tsx | 36 +- .../DashboardCareerOpportunities.tsx | 136 ++ .../_components/DashboardWeeklyTop.tsx | 20 +- .../app/home/_components/DashboardClient.tsx | 6 +- .../app/home/_components/NewsBanner.tsx | 135 ++ .../app/home/admin-dashboard/layout.tsx | 2 +- .../app/home/feeds/_components/Feed.tsx | 2 +- .../app/home/feeds/_components/PostUpvote.tsx | 36 +- .../app/home/feeds/_components/PostView.tsx | 39 +- .../feeds/_components/PostViewCommentItem.tsx | 74 + .../feeds/_components/PostViewCommentList.tsx | 79 + .../_components/PostViewCreateComment.tsx | 61 + .../home/hire/_components/HirePageClient.tsx | 4 +- .../hire/_components/JobListingsTable.tsx | 195 +- apps/codebility/app/home/hire/actions.ts | 66 + apps/codebility/app/home/hire/page.tsx | 2 +- apps/codebility/app/home/page.tsx | 4 + .../_components/BannerImageUpload.tsx | 214 +++ .../_components/NewsBannerForm.tsx | 262 +++ .../app/home/settings/news-banners/actions.ts | 289 +++ .../app/home/settings/news-banners/page.tsx | 299 +++ apps/codebility/app/home/settings/page.tsx | 1 + .../services/_components/ServiceForm.tsx | 267 +++ .../app/home/settings/services/actions.ts | 93 + .../app/home/settings/services/page.tsx | 1631 +++++++++++++++++ apps/codebility/constants/settings.ts | 14 + apps/codebility/middleware.ts | 3 +- apps/codebility/package.json | 1 + .../assets/svgs/techstack/icon-nextjs.svg | 4 +- .../migrations/add_dummy_news_banners.sql | 49 + .../add_image_url_to_news_banners.sql | 15 + .../migrations/create_news_banner_system.sql | 50 + .../migrations/create_proposal_system.sql | 65 + pnpm-lock.yaml | 10 + 34 files changed, 4098 insertions(+), 66 deletions(-) create mode 100644 apps/codebility/app/home/(dashboard)/_components/DashboardCareerOpportunities.tsx create mode 100644 apps/codebility/app/home/_components/NewsBanner.tsx create mode 100644 apps/codebility/app/home/feeds/_components/PostViewCommentItem.tsx create mode 100644 apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx create mode 100644 apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx create mode 100644 apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx create mode 100644 apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx create mode 100644 apps/codebility/app/home/settings/news-banners/actions.ts create mode 100644 apps/codebility/app/home/settings/news-banners/page.tsx create mode 100644 apps/codebility/app/home/settings/services/_components/ServiceForm.tsx create mode 100644 apps/codebility/app/home/settings/services/actions.ts create mode 100644 apps/codebility/app/home/settings/services/page.tsx create mode 100644 apps/codebility/supabase/migrations/add_dummy_news_banners.sql create mode 100644 apps/codebility/supabase/migrations/add_image_url_to_news_banners.sql create mode 100644 apps/codebility/supabase/migrations/create_news_banner_system.sql create mode 100644 apps/codebility/supabase/migrations/create_proposal_system.sql diff --git a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsOrbitingCirclesBg.tsx b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsOrbitingCirclesBg.tsx index b65934345..534a9f10a 100644 --- a/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsOrbitingCirclesBg.tsx +++ b/apps/codebility/app/(marketing)/hire-a-codev/_components/CodevsOrbitingCirclesBg.tsx @@ -12,40 +12,60 @@ const OrbitingCirclesBackground = () => {
{/* Inner Circles */} - +
+ +
- +
+ +
{/* Outer Circles (reverse) */} - +
+ +
- +
+ +
); diff --git a/apps/codebility/app/home/(dashboard)/_components/DashboardCareerOpportunities.tsx b/apps/codebility/app/home/(dashboard)/_components/DashboardCareerOpportunities.tsx new file mode 100644 index 000000000..eb20bb6ea --- /dev/null +++ b/apps/codebility/app/home/(dashboard)/_components/DashboardCareerOpportunities.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { ArrowRight, Briefcase, TrendingUp, Star } from "lucide-react"; +import Link from "next/link"; +import { Button } from "@codevs/ui/button"; +import Box from "@/components/shared/dashboard/Box"; +import { useUserStore } from "@/store/codev-store"; + +export default function DashboardCareerOpportunities() { + const { user } = useUserStore(); + + // Check if user is a Codev (role_id = 10) + const isCodev = user?.role_id === 10; + + return ( + + {/* Main background gradient */} +
+ + {/* Background gradient decorations */} +
+
+
+
+ +
+ {/* Header */} +
+
+ +
+
+

+ Career Opportunities +

+

+ Exclusive opportunities for Codevs +

+
+
+ + {/* Content */} +
+ {/* Quick stats */} +
+
+
+ + Growth +
+

+ Career Paths +

+
+ +
+
+ + Premium +
+

+ Opportunities +

+
+
+ + {/* Description */} +
+

+ {isCodev ? ( + <>🚀 Unlock exclusive career opportunities designed specifically for experienced Codevs. + Explore senior positions, leadership roles, and high-impact projects. + ) : ( + <>🔒 Exclusive career opportunities available for verified Codev members. + Complete your journey to unlock premium career paths and opportunities. + )} +

+ +
+ + + Senior Roles + + + + Leadership Tracks + + + + Remote Work + +
+
+ + {/* CTA Button */} + {isCodev ? ( + + + + ) : ( + + )} + + {/* Footer note */} +

+ {isCodev + ? "Available exclusively for verified Codev members" + : "Complete your journey to become a verified Codev member" + } +

+
+
+ + ); +} \ No newline at end of file diff --git a/apps/codebility/app/home/(dashboard)/_components/DashboardWeeklyTop.tsx b/apps/codebility/app/home/(dashboard)/_components/DashboardWeeklyTop.tsx index 320fce6fc..aa33986c1 100644 --- a/apps/codebility/app/home/(dashboard)/_components/DashboardWeeklyTop.tsx +++ b/apps/codebility/app/home/(dashboard)/_components/DashboardWeeklyTop.tsx @@ -414,26 +414,26 @@ export default function WeeklyTop() { // Soft skills icons switch (rank) { case 1: - return ; + return ; case 2: - return ; + return ; case 3: - return ; + return ; default: - return ; + return ; } } // Technical skills icons switch (rank) { case 1: - return ; + return ; case 2: - return ; + return ; case 3: - return ; + return ; default: - return ; + return ; } }; @@ -859,7 +859,7 @@ export default function WeeklyTop() {
- +

@@ -914,7 +914,7 @@ export default function WeeklyTop() {
- +

diff --git a/apps/codebility/app/home/_components/DashboardClient.tsx b/apps/codebility/app/home/_components/DashboardClient.tsx index adf5266aa..3b99e6935 100644 --- a/apps/codebility/app/home/_components/DashboardClient.tsx +++ b/apps/codebility/app/home/_components/DashboardClient.tsx @@ -8,6 +8,7 @@ import Profile from "../(dashboard)/_components/DashboardProfile"; import TokenPoints from "../(dashboard)/_components/DashboardTokenPoints"; import WeeklyTop from "../(dashboard)/_components/DashboardWeeklyTop"; import DashboardCompleteProfile from "../(dashboard)/_components/DashboardCompleteProfile"; +import DashboardCareerOpportunities from "../(dashboard)/_components/DashboardCareerOpportunities"; export default function DashboardClient() { const { user } = useUserStore(); @@ -45,7 +46,10 @@ export default function DashboardClient() {

- +
+ + +
{userIsInactive && ( diff --git a/apps/codebility/app/home/_components/NewsBanner.tsx b/apps/codebility/app/home/_components/NewsBanner.tsx new file mode 100644 index 000000000..84ab9156d --- /dev/null +++ b/apps/codebility/app/home/_components/NewsBanner.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { X, Info, AlertTriangle, CheckCircle, XCircle, Megaphone } from "lucide-react"; +import { createClientClientComponent } from "@/utils/supabase/client"; + +type BannerType = "info" | "warning" | "success" | "error" | "announcement"; + +interface NewsBanner { + id: string; + title: string; + message: string; + type: BannerType; + image_url?: string; + is_active: boolean; + priority: number; + start_date: string; + end_date?: string; +} + +const bannerIcons = { + info: Info, + warning: AlertTriangle, + success: CheckCircle, + error: XCircle, + announcement: Megaphone, +}; + +const bannerStyles = { + info: "bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-200", + warning: "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-200", + success: "bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-200", + error: "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-200", + announcement: "bg-purple-50 border-purple-200 text-purple-800 dark:bg-purple-900/20 dark:border-purple-800 dark:text-purple-200", +}; + +export default function NewsBanner() { + const [banners, setBanners] = useState([]); + const [dismissedBanners, setDismissedBanners] = useState>(new Set()); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchActiveBanners(); + + // Load dismissed banners from localStorage + const dismissed = localStorage.getItem("dismissedBanners"); + if (dismissed) { + setDismissedBanners(new Set(JSON.parse(dismissed))); + } + }, []); + + const fetchActiveBanners = async () => { + try { + const supabase = createClientClientComponent(); + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("news_banners") + .select("*") + .eq("is_active", true) + .or(`end_date.is.null,end_date.gte.${now}`) + .lte("start_date", now) + .order("priority", { ascending: false }) + .order("created_at", { ascending: false }); + + if (error) { + console.error("Error fetching banners:", error.message); + setBanners([]); + } else { + setBanners(data || []); + } + } catch (err) { + console.error("Error fetching banners:", err); + setBanners([]); + } finally { + setLoading(false); + } + }; + + const dismissBanner = (bannerId: string) => { + const newDismissed = new Set(dismissedBanners); + newDismissed.add(bannerId); + setDismissedBanners(newDismissed); + localStorage.setItem("dismissedBanners", JSON.stringify([...newDismissed])); + }; + + const visibleBanners = banners.filter(banner => !dismissedBanners.has(banner.id)); + + if (loading || visibleBanners.length === 0) { + return null; + } + + return ( +
+ {visibleBanners.map((banner) => { + const Icon = bannerIcons[banner.type]; + + return ( +
+
+ {/* Banner Image or Icon */} + {banner.image_url ? ( +
+ {banner.title} +
+ ) : ( + + )} + +
+

{banner.title}

+

{banner.message}

+
+
+ + +
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/apps/codebility/app/home/admin-dashboard/layout.tsx b/apps/codebility/app/home/admin-dashboard/layout.tsx index 0e5385867..969c7e372 100644 --- a/apps/codebility/app/home/admin-dashboard/layout.tsx +++ b/apps/codebility/app/home/admin-dashboard/layout.tsx @@ -6,7 +6,7 @@ export default function AdminDashboardLayout({ children: React.ReactNode; }) { return ( -
+
{/* Background decorations */}
diff --git a/apps/codebility/app/home/feeds/_components/Feed.tsx b/apps/codebility/app/home/feeds/_components/Feed.tsx index 515d4cd50..cefd172f5 100644 --- a/apps/codebility/app/home/feeds/_components/Feed.tsx +++ b/apps/codebility/app/home/feeds/_components/Feed.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from "react"; import DefaultPagination from "@/components/ui/pagination"; import { useFeedsStore } from "@/store/feeds-store"; // Adjust path as needed -import Post from "./Post"; +import Post from "./PostCard"; interface FeedProp { isAdmin: boolean; diff --git a/apps/codebility/app/home/feeds/_components/PostUpvote.tsx b/apps/codebility/app/home/feeds/_components/PostUpvote.tsx index 5e528308b..f79a94a98 100644 --- a/apps/codebility/app/home/feeds/_components/PostUpvote.tsx +++ b/apps/codebility/app/home/feeds/_components/PostUpvote.tsx @@ -2,10 +2,12 @@ import { useEffect, useState } from "react"; import { useUserStore } from "@/store/codev-store"; +import { useFeedsStore } from "@/store/feeds-store"; import { ArrowBigUp } from "lucide-react"; import { AddPostUpvote, + countUpvotes, hasUserUpvoted, removePostUpvote, } from "../_services/action"; @@ -19,7 +21,11 @@ export default function PostUpvote({ post }: PostUpvoteProps) { const { user } = useUserStore(); const [isUpvoted, setIsUpvoted] = useState(false); - const [upvotes, setUpvotes] = useState(post.upvoters_id?.length || 0); + const [upvotes, setUpvotes] = useState(0); + const { posts, fetchPosts } = useFeedsStore((state) => ({ + posts: state.posts, + fetchPosts: state.fetchPosts, + })); useEffect(() => { const checkUpvote = async () => { @@ -34,24 +40,34 @@ export default function PostUpvote({ post }: PostUpvoteProps) { } else { setIsUpvoted(false); } - }; + if (post?.id) { + try { + const upvotesCount = await countUpvotes(post.id); + setUpvotes(upvotesCount); + } catch (error) { + console.error("Error counting upvotes:", error); + } + } + }; checkUpvote(); - }, [user?.id, post?.id]); + }, [user?.id, post?.id, posts]); - const handleUpvote = (e: React.MouseEvent) => { + const handleUpvote = async (e: React.MouseEvent) => { if (user) { e.stopPropagation(); e.preventDefault(); + setIsUpvoted((prev) => !prev); + setUpvotes((prev) => prev + (isUpvoted ? -1 : 1)); + if (!isUpvoted) { - AddPostUpvote(post.id, user.id); + const postUpvote = await AddPostUpvote(post.id, user.id); } else { - removePostUpvote(post.id, user.id); + await removePostUpvote(post.id, user.id); } - setIsUpvoted((prev) => !prev); - setUpvotes((prev) => prev + (isUpvoted ? -1 : 1)); + await fetchPosts(); } }; @@ -59,8 +75,8 @@ export default function PostUpvote({ post }: PostUpvoteProps) { + )} +
+
+ - {(isAdmin || isAuthor) && ( - - )} { + setIsVisible(false); + try { + await deletePostComment(commenntId); + } catch (error) { + setIsVisible(true); + // Optionally, you could log the error or show a message to the user + } + }; + + if (!isVisible) return null; + + return ( +
+ {/* Avatar */} + {`${userName}'s + + {/* User content */} +
+ + {userName} + +

+ {content} +

+
+ + {/* X Icon (only visible on hover) */} + {userCanDelete && ( + + )} +
+ ); +} diff --git a/apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx b/apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx new file mode 100644 index 000000000..331f27b07 --- /dev/null +++ b/apps/codebility/app/home/feeds/_components/PostViewCommentList.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { defaultAvatar } from "@/public/assets/images"; +import { useUserStore } from "@/store/codev-store"; + +import { getPostComments } from "../_services/action"; +import PostViewCommentItem from "./PostViewCommentItem"; + +interface PostViewCommentListProps { + postId: string; + refresh: number; + hasDeleteCommentPrivilege: boolean; +} + +interface Comment { + id: string; + content: string; + created_at: string; + commenter: any; +} + +export default function PostViewCommentList({ + postId, + refresh, + hasDeleteCommentPrivilege, +}: PostViewCommentListProps) { + const [comments, setComments] = useState([]); + const [loading, setLoading] = useState(true); + const { user } = useUserStore(); + + useEffect(() => { + const fetchComments = async () => { + try { + const data = await getPostComments(postId); + setComments(data); + } catch (error) { + console.error("Error fetching comments:", error); + } finally { + setLoading(false); + } + }; + + fetchComments(); + }, [postId, refresh]); + + if (loading) { + return ( +

+ Loading comments… +

+ ); + } + + if (comments.length === 0) { + return ( +

+ No comments yet. +

+ ); + } + + return ( +
+ {comments.map((c) => ( + + ))} +
+ ); +} diff --git a/apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx b/apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx new file mode 100644 index 000000000..30e738811 --- /dev/null +++ b/apps/codebility/app/home/feeds/_components/PostViewCreateComment.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import Image from "next/image"; +import { defaultAvatar } from "@/public/assets/images"; +import { useUserStore } from "@/store/codev-store"; + +import { createComment } from "../_services/action"; + +interface CreateCommentProps { + postId: string; + onCommentCreated?: () => void; +} + +export default function PostViewCreateComment({ + postId, + onCommentCreated, +}: CreateCommentProps) { + const [comment, setComment] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const { user } = useUserStore(); + + const handleKeyDown = async (e: React.KeyboardEvent) => { + if (e.key === "Enter" && comment.trim() && !isSubmitting) { + if (!user || !user.id) return; + try { + setIsSubmitting(true); + await createComment(postId, user.id, comment); + setComment(""); + onCommentCreated?.(); + } finally { + setIsSubmitting(false); + } + } + }; + + return ( +
+ User avatar + setComment(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isSubmitting} + className={`flex-1 rounded-full border border-gray-300 bg-white px-4 py-2 text-sm outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white ${ + isSubmitting + ? "cursor-not-allowed opacity-50" + : "focus:border-gray-500" + }`} + /> +
+ ); +} diff --git a/apps/codebility/app/home/hire/_components/HirePageClient.tsx b/apps/codebility/app/home/hire/_components/HirePageClient.tsx index b749d41a4..d7233e2fd 100644 --- a/apps/codebility/app/home/hire/_components/HirePageClient.tsx +++ b/apps/codebility/app/home/hire/_components/HirePageClient.tsx @@ -18,7 +18,9 @@ export default function HirePageClient() {

Active Job Listings

- +
+ +
); } \ No newline at end of file diff --git a/apps/codebility/app/home/hire/_components/JobListingsTable.tsx b/apps/codebility/app/home/hire/_components/JobListingsTable.tsx index 645e4ba4d..32852eb67 100644 --- a/apps/codebility/app/home/hire/_components/JobListingsTable.tsx +++ b/apps/codebility/app/home/hire/_components/JobListingsTable.tsx @@ -12,7 +12,10 @@ import { MapPin, DollarSign, User, - MoreHorizontal + MoreHorizontal, + CheckCircle, + XCircle, + RotateCcw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@codevs/ui/badge"; @@ -36,7 +39,7 @@ import EditJobModal from "./EditJobModal"; import { createClientClientComponent } from "@/utils/supabase/client"; import { Skeleton } from "@/components/ui/skeleton/skeleton"; import { useUserStore } from "@/store/codev-store"; -import { deleteJobListing } from "../actions"; +import { deleteJobListing, updateJobListingStatus } from "../actions"; import { Accordion, @@ -46,6 +49,7 @@ import { } from "@codevs/ui/accordion" interface JobWithApplicationCount extends JobListing { application_count?: number; + status?: "active" | "closed" | "draft"; created_by_details?: { id: string; first_name: string; @@ -62,10 +66,17 @@ export default function JobListingsTable() { const [loading, setLoading] = useState(true); const [editingJob, setEditingJob] = useState(null); const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'closed' | 'draft'>('all'); // Check if user is admin (role_id 1 or 4) const isAdmin = user?.role_id === 1 || user?.role_id === 4; + // Filter jobs based on status + const filteredJobs = jobs.filter(job => { + if (statusFilter === 'all') return true; + return (job.status || 'active') === statusFilter; + }); + useEffect(() => { fetchJobListings(); }, []); @@ -183,6 +194,38 @@ export default function JobListingsTable() { router.push(`/home/hire/applications/${jobId}`); }; + const handleStatusChange = async (jobId: string, newStatus: "active" | "closed" | "draft") => { + try { + const result = await updateJobListingStatus(jobId, newStatus); + + if (!result.success) { + throw new Error(result.error || "Failed to update job status"); + } + + // Update local state + setJobs(prevJobs => + prevJobs.map(job => + job.id === jobId + ? { ...job, status: newStatus } + : job + ) + ); + + const statusText = newStatus === 'closed' ? 'closed' : 'reopened'; + toast({ + title: `Job ${statusText}`, + description: `The job listing has been ${statusText} successfully.`, + }); + } catch (error) { + console.error('Status update error:', error); + toast({ + title: "Failed to update status", + description: error instanceof Error ? error.message : "Please try again later.", + variant: "destructive", + }); + } + }; + const getLevelColor = (level: string) => { switch (level) { case "Entry": @@ -213,6 +256,19 @@ export default function JobListingsTable() { } }; + const getStatusColor = (status: string) => { + switch (status) { + case "active": + return "bg-green-500/10 text-green-400 border-green-500/20"; + case "closed": + return "bg-red-500/10 text-red-400 border-red-500/20"; + case "draft": + return "bg-gray-500/10 text-gray-400 border-gray-500/20"; + default: + return "bg-gray-500/10 text-muted-foreground border-gray-500/20"; + } + }; + if (loading) { return (
@@ -236,7 +292,42 @@ export default function JobListingsTable() { } return ( -
+
+ {/* Filter Bar */} +
+ Filter by status: +
+ {[ + { value: 'all', label: 'All', count: jobs.length }, + { value: 'active', label: 'Active', count: jobs.filter(j => (j.status || 'active') === 'active').length }, + { value: 'closed', label: 'Closed', count: jobs.filter(j => j.status === 'closed').length }, + { value: 'draft', label: 'Draft', count: jobs.filter(j => j.status === 'draft').length } + ].map(({ value, label, count }) => ( + + ))} +
+
+ + {/* Jobs Table */} + {filteredJobs.length === 0 ? ( +
+

+ {statusFilter === 'all' + ? "No job listings yet. Create your first job listing to get started." + : `No ${statusFilter} job listings found.` + } +

+
+ ) : ( +
@@ -244,6 +335,7 @@ export default function JobListingsTable() { Position Location Type + Status Applications Created By Posted @@ -251,7 +343,7 @@ export default function JobListingsTable() { - {jobs.map((job) => ( + {filteredJobs.map((job) => (
@@ -279,6 +371,11 @@ export default function JobListingsTable() { {job.type} + + + {job.status || 'active'} + + - handleEdit(job)} - className="text-foreground hover:text-foreground hover:bg-accent" - > - - Edit Listing - - handleDelete(job.id)} - className="text-destructive hover:text-destructive hover:bg-accent " - > - - Delete - + {(isAdmin || job.created_by?.id === user?.id) && ( + <> + handleEdit(job)} + className="text-foreground hover:text-foreground hover:bg-accent" + > + + Edit Listing + + {job.status === 'active' ? ( + handleStatusChange(job.id, 'closed')} + className="text-orange-500 hover:text-orange-400 hover:bg-accent" + > + + Mark as Closed + + ) : ( + handleStatusChange(job.id, 'active')} + className="text-green-500 hover:text-green-400 hover:bg-accent" + > + + Reopen Position + + )} + handleDelete(job.id)} + className="text-destructive hover:text-destructive hover:bg-accent " + > + + Delete + + + )} + {!isAdmin && job.created_by?.id !== user?.id && ( + + No actions available + + )}
@@ -355,7 +478,7 @@ export default function JobListingsTable() { {/* Mobile Accordion */}
- {jobs.map((job) => ( + {filteredJobs.map((job) => (
@@ -368,6 +491,9 @@ export default function JobListingsTable() { {job.type} + + {job.status || 'active'} + {job.remote && ( Remote @@ -496,7 +622,7 @@ export default function JobListingsTable() { - {(isAdmin || job.created_by === user?.id) && ( + {(isAdmin || job.created_by?.id === user?.id) && ( <> { @@ -509,6 +635,31 @@ export default function JobListingsTable() { Edit Listing + {job.status === 'active' ? ( + { + e.preventDefault(); + e.stopPropagation(); + handleStatusChange(job.id, 'closed'); + }} + className="text-orange-400 hover:text-orange-300 hover:bg-gray-800 cursor-pointer" + > + + Mark as Closed + + ) : ( + { + e.preventDefault(); + e.stopPropagation(); + handleStatusChange(job.id, 'active'); + }} + className="text-green-400 hover:text-green-300 hover:bg-gray-800 cursor-pointer" + > + + Reopen Position + + )} { e.preventDefault(); @@ -522,7 +673,7 @@ export default function JobListingsTable() { )} - {!isAdmin && job.created_by !== user?.id && ( + {!isAdmin && job.created_by?.id !== user?.id && ( No actions available @@ -543,6 +694,8 @@ export default function JobListingsTable() { onClose={handleEditModalClose} onJobUpdated={handleJobUpdated} /> +
+ )}
); } \ No newline at end of file diff --git a/apps/codebility/app/home/hire/actions.ts b/apps/codebility/app/home/hire/actions.ts index 480372bf7..017208d5d 100644 --- a/apps/codebility/app/home/hire/actions.ts +++ b/apps/codebility/app/home/hire/actions.ts @@ -277,4 +277,70 @@ export async function deleteJobApplication(applicationId: string, jobId: string) console.error("Server action error:", error); return { success: false, error: "An unexpected error occurred" }; } +} + +export async function updateJobListingStatus( + jobId: string, + status: "active" | "closed" | "draft" +) { + try { + const supabase = await createClientServerComponent(); + + // Get the current user + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { + return { success: false, error: "Unauthorized" }; + } + + // Get user details to check role + const { data: userData } = await supabase + .from('codev') + .select('id, role_id') + .eq('id', user.id) + .single(); + + if (!userData) { + return { success: false, error: "User not found" }; + } + + // Check if user is admin (role_id 1 or 4) + const isAdmin = userData.role_id === 1 || userData.role_id === 4; + + // Get the job to check ownership + const { data: job } = await supabase + .from('job_listings') + .select('created_by, status') + .eq('id', jobId) + .single(); + + if (!job) { + return { success: false, error: "Job not found" }; + } + + // Check permissions + if (!isAdmin && job.created_by !== userData.id) { + return { success: false, error: "You don't have permission to update this job" }; + } + + // Update the job status + const { data: updatedJob, error } = await supabase + .from('job_listings') + .update({ + status, + updated_at: new Date().toISOString(), + }) + .eq('id', jobId) + .select() + .single(); + + if (error) { + console.error("Update status error:", error); + return { success: false, error: error.message }; + } + + return { success: true, data: updatedJob }; + } catch (error) { + console.error("Server action error:", error); + return { success: false, error: "An unexpected error occurred" }; + } } \ No newline at end of file diff --git a/apps/codebility/app/home/hire/page.tsx b/apps/codebility/app/home/hire/page.tsx index c73716dcc..b5ec3ec26 100644 --- a/apps/codebility/app/home/hire/page.tsx +++ b/apps/codebility/app/home/hire/page.tsx @@ -64,7 +64,7 @@ export default async function HirePage() { return (
-
+
diff --git a/apps/codebility/app/home/page.tsx b/apps/codebility/app/home/page.tsx index 5d9b31c26..4deeaa793 100644 --- a/apps/codebility/app/home/page.tsx +++ b/apps/codebility/app/home/page.tsx @@ -2,6 +2,7 @@ import { Suspense } from "react"; import H1 from "@/components/shared/dashboard/H1"; import PageContainer from "./_components/PageContainer"; import DashboardClient from "./_components/DashboardClient"; +import NewsBanner from "./_components/NewsBanner"; // Loading component for better UX function DashboardLoading() { @@ -37,6 +38,9 @@ export default function DashboardPage() {
+ {/* News Banner */} + + {/* Enhanced Header */}
diff --git a/apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx b/apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx new file mode 100644 index 000000000..ea69c70a5 --- /dev/null +++ b/apps/codebility/app/home/settings/news-banners/_components/BannerImageUpload.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import { Upload, X, Image as ImageIcon } from "lucide-react"; +import { Button } from "@codevs/ui/button"; +import { Label } from "@codevs/ui/label"; +import { uploadImage, deleteImage, getImagePath } from "@/utils/uploadImage"; +import { toast } from "sonner"; + +interface BannerImageUploadProps { + value?: string; // Current image URL + onChange: (imageUrl: string | null) => void; + disabled?: boolean; +} + +export default function BannerImageUpload({ + value, + onChange, + disabled = false +}: BannerImageUploadProps) { + const [isUploading, setIsUploading] = useState(false); + const [preview, setPreview] = useState(value || null); + const fileInputRef = useRef(null); + + const handleFileSelect = useCallback(async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + toast.error("Please select an image file"); + return; + } + + // Validate file size (max 5MB) + if (file.size > 5 * 1024 * 1024) { + toast.error("Image size must be less than 5MB"); + return; + } + + setIsUploading(true); + + try { + // Create preview + const previewUrl = URL.createObjectURL(file); + setPreview(previewUrl); + + // Upload to Supabase storage in 'banner' folder + const publicUrl = await uploadImage(file, { + bucket: "codebility", + folder: "banner", + cacheControl: "3600", + upsert: true + }); + + // Clean up old image if exists + if (value) { + const oldImagePath = getImagePath(value); + if (oldImagePath) { + await deleteImage(oldImagePath, "codebility"); + } + } + + // Clean up preview URL + URL.revokeObjectURL(previewUrl); + + onChange(publicUrl); + setPreview(publicUrl); + toast.success("Banner image uploaded successfully"); + } catch (error) { + console.error("Upload error:", error); + toast.error("Failed to upload image"); + setPreview(value || null); + } finally { + setIsUploading(false); + // Reset input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, [value, onChange]); + + const handleRemove = useCallback(async () => { + if (!value) return; + + setIsUploading(true); + try { + // Delete from storage + const imagePath = getImagePath(value); + if (imagePath) { + await deleteImage(imagePath, "codebility"); + } + + onChange(null); + setPreview(null); + toast.success("Banner image removed"); + } catch (error) { + console.error("Delete error:", error); + toast.error("Failed to remove image"); + } finally { + setIsUploading(false); + } + }, [value, onChange]); + + const triggerFileInput = () => { + fileInputRef.current?.click(); + }; + + return ( +
+ + +
+ {/* Image Preview */} + {preview ? ( +
+
+ Banner preview + {/* Overlay with remove button */} +
+ +
+
+
+ ) : ( + /* Upload Area */ +
+ {isUploading ? ( +
+
+ Uploading... +
+ ) : ( + <> + +
+

+ Click to upload banner image +

+

+ PNG, JPG up to 5MB +

+
+ + )} +
+ )} + + {/* Upload Button */} +
+ + + {preview && ( + + )} +
+
+ + {/* Hidden file input */} + + +

+ Banner images help make announcements more visually appealing and attention-grabbing. +

+
+ ); +} \ No newline at end of file diff --git a/apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx b/apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx new file mode 100644 index 000000000..307784c9d --- /dev/null +++ b/apps/codebility/app/home/settings/news-banners/_components/NewsBannerForm.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@codevs/ui/button"; +import { Input } from "@codevs/ui/input"; +import { Textarea } from "@codevs/ui/textarea"; +import { Label } from "@codevs/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@codevs/ui/select"; +import { Switch } from "@codevs/ui/switch"; +import { createClientClientComponent } from "@/utils/supabase/client"; +import { toast } from "sonner"; +import BannerImageUpload from "./BannerImageUpload"; + +const bannerSchema = z.object({ + title: z.string().min(1, "Title is required").max(255, "Title too long"), + message: z.string().min(1, "Message is required"), + type: z.enum(["info", "warning", "success", "error", "announcement"]), + image_url: z.string().optional(), + priority: z.number().min(1).max(100), + is_active: z.boolean(), + start_date: z.string(), + end_date: z.string().optional(), +}); + +type BannerFormData = z.infer; + +interface NewsBanner { + id: string; + title: string; + message: string; + type: "info" | "warning" | "success" | "error" | "announcement"; + image_url?: string; + is_active: boolean; + priority: number; + start_date: string; + end_date?: string; +} + +interface NewsBannerFormProps { + banner?: NewsBanner | null; + onSuccess: () => void; +} + +export default function NewsBannerForm({ banner, onSuccess }: NewsBannerFormProps) { + const [loading, setLoading] = useState(false); + const [selectedType, setSelectedType] = useState(banner?.type || "info"); + + const { + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm({ + resolver: zodResolver(bannerSchema), + defaultValues: { + title: banner?.title || "", + message: banner?.message || "", + type: banner?.type || "info", + image_url: banner?.image_url || "", + priority: banner?.priority || 1, + is_active: banner?.is_active ?? true, + start_date: banner?.start_date ? new Date(banner.start_date).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16), + end_date: banner?.end_date ? new Date(banner.end_date).toISOString().slice(0, 16) : "", + }, + }); + + const isActive = watch("is_active"); + + const onSubmit = async (data: BannerFormData) => { + setLoading(true); + const supabase = createClientClientComponent(); + + try { + const bannerData = { + title: data.title, + message: data.message, + type: data.type, + image_url: data.image_url || null, + priority: data.priority, + is_active: data.is_active, + start_date: new Date(data.start_date).toISOString(), + end_date: data.end_date ? new Date(data.end_date).toISOString() : null, + updated_at: new Date().toISOString(), + }; + + let result; + if (banner) { + // Update existing banner + result = await supabase + .from("news_banners") + .update(bannerData) + .eq("id", banner.id); + } else { + // Create new banner + const { data: user } = await supabase.auth.getUser(); + result = await supabase + .from("news_banners") + .insert({ + ...bannerData, + created_by: user.user?.id, + }); + } + + if (result.error) { + throw result.error; + } + + toast.success(banner ? "Banner updated successfully" : "Banner created successfully"); + onSuccess(); + } catch (error) { + console.error("Error saving banner:", error); + toast.error("Failed to save banner"); + } finally { + setLoading(false); + } + }; + + const formatDateTimeLocal = (date: string) => { + if (!date) return ""; + return new Date(date).toISOString().slice(0, 16); + }; + + return ( +
+
+
+ + + {errors.title && ( +

{errors.title.message}

+ )} +
+ +
+ + + {errors.type && ( +

{errors.type.message}

+ )} +
+
+ +
+ +