diff --git a/app/globals.css b/app/globals.css index 3a201945..04a3e253 100644 --- a/app/globals.css +++ b/app/globals.css @@ -17,6 +17,31 @@ } } +@keyframes shiny { + 0% { + background-position: -1000px 0; + } + 100% { + background-position: 1000px 0; + } +} + +.shiny-text { + background: linear-gradient( + 90deg, + #ffd700 0%, + #fff44f 25%, + #ffeb3b 50%, + #fff44f 75%, + #ffd700 100% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: shiny 3s linear infinite; +} + @layer base { :root { --background: 0 0% 100%; diff --git a/app/hire/dashboard/page.tsx b/app/hire/dashboard/page.tsx index 38e32a2c..3b3e8371 100644 --- a/app/hire/dashboard/page.tsx +++ b/app/hire/dashboard/page.tsx @@ -10,7 +10,7 @@ import { Loader } from "@/components/ui/loader"; import { useEmployerApplications, useOwnedJobs, useProfile } from "@/hooks/use-employer-api"; import { useMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; -import { Bell, Plus } from "lucide-react"; +import { Bell, Briefcase, Plus } from "lucide-react"; import Link from "next/link"; import { useState, useRef, useEffect } from "react"; import { useAuthContext } from "../authctx"; @@ -18,6 +18,7 @@ import { Job } from "@/lib/db/db.types"; import { FadeIn } from "@/components/animata/fade"; import { useModal } from "@/hooks/use-modal"; import { getNotificationPermission, requestNotificationPermission, checkNotificationSupport, shouldShowNotification, sendNotification } from "@/lib/notification-service"; +import { HeaderIcon, HeaderText } from "@/components/ui/text"; function DashboardContent() { const { isMobile } = useMobile(); @@ -86,31 +87,34 @@ function DashboardContent() {
-

Welcome back, {profile.data?.name}

+
+ + Job listings +
-
-
- {activeJobs.length} active listing{activeJobs.length !== 1 ? "s" : ""} - {inactiveJobs.length} inactive listing{inactiveJobs.length !== 1 ? "s" : ""} -
- - {isMobile && ( - - - - )} -
+
+
+ {activeJobs.length} active listing{activeJobs.length !== 1 ? "s" : ""} + {inactiveJobs.length} inactive listing{inactiveJobs.length !== 1 ? "s" : ""} +
+ + {isMobile && ( + + + + )} +
diff --git a/app/hire/forgot-password/page.tsx b/app/hire/forgot-password/page.tsx index a789931d..84e8dc71 100644 --- a/app/hire/forgot-password/page.tsx +++ b/app/hire/forgot-password/page.tsx @@ -9,6 +9,8 @@ import { EmployerUserService } from "@/lib/api/services"; import { cn } from "@/lib/utils"; import { useAppContext } from "@/lib/ctx-app"; import { AnimatePresence, motion } from "framer-motion"; +import { HeaderIcon, HeaderText } from "@/components/ui/text"; +import { HelpCircle } from "lucide-react"; /** * Display the layout for the forgot password page. @@ -68,9 +70,10 @@ const ForgotPasswordForm = ({}) => { className="w-full" > -

- Forgot password -

+
+ + Reset password +
{error && (

{error}

@@ -86,7 +89,10 @@ const ForgotPasswordForm = ({}) => { onChange={(e) => setEmail(e.target.value)} value={email} /> -
+
+ + Remember your password? Log in here. +
- - Remember your password? Log in here. - Need help? Contact us at 0927 660 4999 or on Viber. diff --git a/app/hire/help/page.tsx b/app/hire/help/page.tsx index 32fa37aa..6a66f59a 100644 --- a/app/hire/help/page.tsx +++ b/app/hire/help/page.tsx @@ -3,9 +3,10 @@ import ContentLayout from "@/components/features/hire/content-layout"; import { Card } from "@/components/ui/card"; import { useAppContext } from "@/lib/ctx-app"; import { cn } from "@/lib/utils"; -import { BadgeInfo, Bug, Calendar, Facebook, LucideMessageCircleMore, Mail, Phone } from "lucide-react"; +import { BadgeInfo, Bug, Calendar, Facebook, HelpCircle, LucideMessageCircleMore, Mail, Phone } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import Link from "next/link"; +import { HeaderIcon, HeaderText } from "@/components/ui/text"; export default function HelpPage() { const { isMobile } = useAppContext(); @@ -25,7 +26,10 @@ export default function HelpPage() { transition={{ duration: 0.3, ease: "easeOut" }} > -

Help center

+
+ + Help +
) => { + setJobData(prev => prev ? { ...prev, ...updates } : null); + }; + if (loading || !jobData) { return ( @@ -55,11 +60,17 @@ function JobDetailsPageRouteContent() { } return ( - +
- +
+ +
) diff --git a/app/hire/login/page.tsx b/app/hire/login/page.tsx index 134cce9f..4d392434 100644 --- a/app/hire/login/page.tsx +++ b/app/hire/login/page.tsx @@ -12,9 +12,10 @@ import { } from "@/components/EditForm"; import { Card } from "@/components/ui/card"; -import { MailCheck, TriangleAlert } from "lucide-react"; +import { MailCheck, TriangleAlert, User } from "lucide-react"; import { Loader } from "@/components/ui/loader"; import { AnimatePresence, motion } from "framer-motion"; +import { HeaderIcon, HeaderText } from "@/components/ui/text"; export default function LoginPage() { return ( @@ -108,13 +109,14 @@ function LoginContent() {
{/* Welcome Message */} -
- Employer Login +
+ + Log in
{/* Error Message */} {error && (
diff --git a/app/hire/register/page.tsx b/app/hire/register/page.tsx index 6e4c203b..9b57a3b6 100644 --- a/app/hire/register/page.tsx +++ b/app/hire/register/page.tsx @@ -20,8 +20,9 @@ import { Loader } from "@/components/ui/loader"; import { cn } from "@/lib/utils"; import { useAppContext } from "@/lib/ctx-app"; import Link from "next/link"; -import { TriangleAlert } from "lucide-react"; +import { TriangleAlert, User } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; +import { HeaderIcon, HeaderText } from "@/components/ui/text"; const [EmployerRegisterForm, useEmployerRegisterForm] = createEditForm(); @@ -189,14 +190,13 @@ const EmployerEditor = ({ className="w-full" > -
-

- Employer Registration -

+
+ + Register
{missingFields.length > 0 && (
@@ -231,7 +231,6 @@ const EmployerEditor = ({ )} />
- field === "Phone number") ? "border-destructive" : "" )} /> +
- field === "Contact email") ? "border-destructive" : "" )} /> +
- -
-
- field === "Legal entity name") ? "border-destructive" : "" )} /> + +
+
+
{ - if (!isAuthenticated()) return; // Exit if not authenticated + if (!isAuthenticated()) { + return; // Exit if not authenticated + } if (!profile.data?.department && !profile.isPending) - router.push("/profile"); + router.push("/profile/complete-profile"); }, [isAuthenticated, profile.data?.department, profile.isPending, router]); // Query 1: Check for updates (cheap query - just a timestamp) diff --git a/app/student/miro/page.tsx b/app/student/miro/page.tsx index cb98db7b..b7539d80 100644 --- a/app/student/miro/page.tsx +++ b/app/student/miro/page.tsx @@ -11,6 +11,194 @@ import { InteractiveGridPattern } from "@/components/landingStudent/sections/1st import { ArrowRight, Circle } from "lucide-react"; import { cn } from "@/lib/utils"; import confetti from "canvas-confetti"; +import { AnimatedShinyText } from "@/components/ui/animated-shiny-text"; + +function EventEndAnimation({ + show, + onDone, +}: { + show: boolean; + onDone?: () => void; +}) { + const [step, setStep] = useState<5 | 4 | 3 | 2 | 1 | 0>(5); + const isAnimatingRef = useRef(false); + + useEffect(() => { + if (!show || isAnimatingRef.current) return; + + isAnimatingRef.current = true; + setStep(5); + + const timeline = [ + { t: 0, value: 5 }, + { t: 1000, value: 4 }, + { t: 2000, value: 3 }, + { t: 3000, value: 2 }, + { t: 4000, value: 1 }, + { t: 5000, value: 0 }, + ]; + + const timeouts: NodeJS.Timeout[] = []; + + timeline.forEach((item) => { + timeouts.push(setTimeout(() => setStep(item.value as any), item.t)); + }); + + // confetti at TIME'S UP + timeouts.push( + setTimeout(() => { + confetti({ + particleCount: 300, + spread: 90, + startVelocity: 60, + scalar: 1.2, + origin: { y: 0.6 }, + }); + + confetti({ + particleCount: 180, + spread: 120, + startVelocity: 50, + scalar: 1, + origin: { y: 0.7 }, + }); + }, 5000), + ); + + timeouts.push( + setTimeout(() => { + isAnimatingRef.current = false; + onDone?.(); + }, 6700), + ); + + return () => { + timeouts.forEach(clearTimeout); + }; + }, [show]); + + const text = step === 0 ? "TIME'S UP!" : step; + + return ( + + {show && ( + + {/* Dark overlay */} + + + {/* Red flash pulse */} + + + {/* Big countdown/text */} + + {/* Glow ring */} + + + {/* Rotating red ring */} + + + {/* Text */} + + {text} + + + + {/* Bottom caption */} + + {step === 0 ? "Event has concluded." : "Time left..."} + + + )} + + ); +} function CountdownDisplayAnimation({ show, @@ -556,7 +744,7 @@ export default function MiroThonLandingPage() { const discordLink = "https://discord.gg/QZ9mXJQm"; const eventStart = useMemo(() => new Date("2026-02-13T18:00:00+08:00"), []); // REAL DATE - const eventEnd = useMemo(() => new Date("2026-02-14T23:59:00+08:00"), []); + const eventEnd = useMemo(() => new Date("2026-02-15T00:00:00+08:00"), []); const [countdown, setCountdown] = useState( getCountdown(eventStart), @@ -567,18 +755,29 @@ export default function MiroThonLandingPage() { return now >= eventStart.getTime() && now < eventEnd.getTime(); }); + const [isEventPast, setIsEventPast] = useState(() => { + const now = new Date().getTime(); + return now > eventEnd.getTime(); + }); + const [showLaunchAnimation, setShowLaunchAnimation] = useState(false); + const [showEndAnimation, setShowEndAnimation] = useState(false); const wasLiveRef = useRef(false); const animationTriggeredRef = useRef(false); + const endAnimationTriggeredRef = useRef(false); useEffect(() => { const interval = setInterval(() => { const now = new Date().getTime(); const isLive = now >= eventStart.getTime() && now < eventEnd.getTime(); + const isPast = now > eventEnd.getTime(); setIsEventLive(isLive); + setIsEventPast(isPast); // Calculate countdown, switching between start and end times - const newCountdown = getCountdown(isLive ? eventEnd : eventStart); + const newCountdown = getCountdown( + isPast ? eventStart : isLive ? eventEnd : eventStart, + ); setCountdown(newCountdown); // Calculate total remaining seconds until eventStart @@ -598,6 +797,22 @@ export default function MiroThonLandingPage() { animationTriggeredRef.current = true; } + // Trigger animation when 5 seconds remain before eventEnd + const secondsUntilEnd = eventEnd.getTime() - now; + const secondsUntilEndValue = Math.max( + 0, + Math.floor(secondsUntilEnd / 1000), + ); + + if ( + secondsUntilEndValue === 5 && + !endAnimationTriggeredRef.current && + isEventLive + ) { + setShowEndAnimation(true); + endAnimationTriggeredRef.current = true; + } + // Reset animation trigger only when event goes live if (isLive && !wasLiveRef.current) { animationTriggeredRef.current = false; @@ -621,6 +836,12 @@ export default function MiroThonLandingPage() { onDone={() => setShowLaunchAnimation(false)} /> + {/* Event End Animation */} + setShowEndAnimation(false)} + /> + {/* texture */}
- {isEventLive ? ( + {isEventPast ? ( + +

+ + You've crossed the finish line + +

+
+ ) : isEventLive ? ( {/* Sticky Note - Desktop: positioned bottom-right, Mobile: below */} - - {!isEventLive && ( - - (yes, that{" "} - + {!isEventLive && ( + - Miro - - ) - - )} - + (yes, that{" "} + + Miro + + ) + + )} + + )}
- {/* COUNTDOWN/COUNTDOWN TIMER */} -
- {(() => { - const totalSeconds = - countdown.days * 86400 + - countdown.hours * 3600 + - countdown.minutes * 60 + - countdown.seconds; - const isUrgent = totalSeconds <= 60 && !isEventLive; - - return ( - <> - -
- : -
- -
- : -
- -
- : -
- - - ); - })()} -
+ {/* COUNTDOWN/COUNTDOWN TIMER - Only show when event is live or before it starts */} + {!isEventPast && ( +
+ {(() => { + const totalSeconds = + countdown.days * 86400 + + countdown.hours * 3600 + + countdown.minutes * 60 + + countdown.seconds; + const isUrgent = totalSeconds <= 60; + + return ( + <> + +
+ : +
+ +
+ : +
+ +
+ : +
+ + + ); + })()} +
+ )} {/* DESCRIPTION */}

{isEventLive ? "Submit your work on Discord. Good luck!" - : "Can you build something in 30 hours that will impress Miro?"} + : isEventPast + ? "Thank you for participating in the Miro-thon" + : "Can you build something in 30 hours that will impress Miro?"}

{/* CTA BUTTONS */} @@ -880,7 +1122,7 @@ export default function MiroThonLandingPage() { className={`w-full h-14 text-lg font-bold bg-blue-600 text-white hover:bg-blue-500`} onClick={openDiscord} > - {isEventLive ? "Join Discord" : "Join the Miro-thon!"} + Join Discord @@ -1015,7 +1257,7 @@ export default function MiroThonLandingPage() { - {/* TIME IS TICKING / EVENT LIVE SECTION */} + {/* TIME IS TICKING / EVENT LIVE / MIRO-THON CONCLUDED SECTION */}
{/* Gradient background effect */}
-
- {/* Left: Title and Timer */} -
-
- {isEventLive ? ( -

- The event is{" "} - LIVE NOW -

- ) : ( -

- Time is ticking -

- )} -
- - {/* Countdown Timer */} -
-
-

- {String(countdown.days).padStart(2, "0")} -

-

- D -

-
-

- : -

-
-

- {String(countdown.hours).padStart(2, "0")} -

-

- H -

+ {isEventPast ? ( + // Miro-thon Concluded State +
+

+ Miro-thon Concluded +

+

+ Thank you for participating in the Miro-thon! +

+
+ ) : ( + // Live or Before Event State +
+ {/* Left: Title and Timer */} +
+
+ {isEventLive ? ( +

+ The event is{" "} + LIVE NOW +

+ ) : ( +

+ Time is ticking +

+ )}
-

- : -

-
-

- {String(countdown.minutes).padStart(2, "0")} -

+ + {/* Countdown Timer */} +
+
+

+ {String(countdown.days).padStart(2, "0")} +

+

+ D +

+

- M + :

-
-

- : -

-
+
+

+ {String(countdown.hours).padStart(2, "0")} +

+

+ H +

+

- {String(countdown.seconds).padStart(2, "0")} + :

+
+

+ {String(countdown.minutes).padStart(2, "0")} +

+

+ M +

+

- S + :

+
+

+ {String(countdown.seconds).padStart(2, "0")} +

+

+ S +

+
-
- {/* Right: CTA Button */} - - - -
+ {/* Right: CTA Button */} + + + +
+ )}
diff --git a/app/student/profile/page.tsx b/app/student/profile/page.tsx index 6f56ad61..aeb53446 100644 --- a/app/student/profile/page.tsx +++ b/app/student/profile/page.tsx @@ -154,7 +154,7 @@ export default function ProfilePage() { !isProfileBaseComplete(profile.data) || profile.data?.acknowledged_auto_apply === false ) { - router.push(`profile/complete-profile?dest=forms`); + router.push(`/forms`); } }, []); diff --git a/components/features/hire/dashboard/ApplicationsCommandBar.tsx b/components/features/hire/dashboard/ApplicationsCommandBar.tsx index 6747a574..036506ec 100644 --- a/components/features/hire/dashboard/ApplicationsCommandBar.tsx +++ b/components/features/hire/dashboard/ApplicationsCommandBar.tsx @@ -86,7 +86,7 @@ export function ApplicationsCommandBar({ {visible && ( <>
) : ( - +
@@ -73,10 +74,10 @@ export default function JobHeader({
-
+
{/* back button */}
-
-
- + + - - + Applicants + + + + - + + Preview + + + -
+ +
diff --git a/components/features/hire/listings/jobDetails.tsx b/components/features/hire/listings/jobDetails.tsx index 2575b280..3f671665 100644 --- a/components/features/hire/listings/jobDetails.tsx +++ b/components/features/hire/listings/jobDetails.tsx @@ -37,12 +37,6 @@ const JobDetailsPage = ({ isMobile ? "px-1" : "" )} > - { @@ -21,15 +21,20 @@ export const ShareJobButton = ({ id }: { id: string }) => { return ( ); }; diff --git a/components/features/student/resume-parser/ResumeUpload.tsx b/components/features/student/resume-parser/ResumeUpload.tsx index 0b763e24..93a059b6 100644 --- a/components/features/student/resume-parser/ResumeUpload.tsx +++ b/components/features/student/resume-parser/ResumeUpload.tsx @@ -1,7 +1,10 @@ import { RefObject, useRef, useState } from "react"; import { AnimatePresence } from "framer-motion"; import { ProcessingTransition } from "./ProcessingTransition"; -import { UploadIcon } from "lucide-react"; +import { TriangleAlert, UploadIcon } from "lucide-react"; +import { Toast } from "@/components/ui/toast"; +import { cn } from "@/lib/utils"; +import { useAppContext } from "@/lib/ctx-app"; const ResumeUpload = ({ ref, @@ -10,7 +13,7 @@ const ResumeUpload = ({ onComplete, isParsing, accept = ".pdf,application/pdf", - maxSizeMB = 5, + maxSizeMB = 2.5, }: { ref: RefObject; promise?: Promise; @@ -22,6 +25,8 @@ const ResumeUpload = ({ }) => { const [file, setFile] = useState(null); const [isDragging, setIsDragging] = useState(false); + const [fileTooBig, setFileTooBig] = useState(false); + const [noFile, setNoFile] = useState(false); const dragCounter = useRef(0); const triggerFileDialog = () => ref.current?.click(); @@ -31,7 +36,7 @@ const ResumeUpload = ({ // simple checks const tooBig = f.size > maxSizeMB * 1024 * 1024; if (tooBig) { - alert(`File must be ≤ ${maxSizeMB}MB.`); + setFileTooBig(true); return; } // accept check (relies on extension or mime) @@ -43,10 +48,12 @@ const ResumeUpload = ({ (a) => !a.startsWith(".") && f.type.toLowerCase() === a ); if (!(nameOk || mimeOk)) { - alert("Please upload a PDF file."); + setNoFile(true); return; } + setFileTooBig(false); + setNoFile(false); setFile(f); onSelect(f); }; @@ -83,12 +90,32 @@ const ResumeUpload = ({ handleChosenFile(f); }; + const { isMobile } = useAppContext(); + return ( {isParsing ? ( ) : (
+ {fileTooBig && +
+ + Please upload a file smaller than 2.5 MB. +
+ } + {noFile && +
+ + Please upload your resume. +
+ }
+ {children} ); diff --git a/components/ui/command-menu.tsx b/components/ui/command-menu.tsx index 546e65f6..44c0597f 100644 --- a/components/ui/command-menu.tsx +++ b/components/ui/command-menu.tsx @@ -43,7 +43,7 @@ export const CommandMenu = ({ onClick={item.onClick} disabled={item.disabled} className={cn( - "flex justify-center items-center rounded-sm gap-2 p-2", + "flex justify-center items-center rounded-[0.33em] gap-2 p-2", "data-[button-layout=vertical]:flex-col", item.destructive ? "text-red-700 hover:bg-red-300/50 active:bg-red-400/75" @@ -75,7 +75,7 @@ export const CommandMenu = ({ role="toolbar" onClick={(e) => e.stopPropagation()} className={cn( - "flex gap-4 px-6 py-2 justify-center items-stretch text-xs bg-white border border-gray-300 rounded-md transition bg-clip-padding bg-clip-border", + "flex gap-4 px-6 py-2 justify-center items-stretch text-xs bg-white border border-gray-300 rounded-[0.33em] transition bg-clip-padding bg-clip-border", className, )} > diff --git a/components/ui/labels.tsx b/components/ui/labels.tsx index cf731a34..d5057251 100644 --- a/components/ui/labels.tsx +++ b/components/ui/labels.tsx @@ -165,7 +165,7 @@ export const EmployerPropertyLabel: ValueComponent = ({ export const ErrorLabel: ValueComponent = ({ value, fallback }) => { return value ? ( -
+
{value ?? fallback}
diff --git a/components/ui/status-badge.tsx b/components/ui/status-badge.tsx index 872fc9d6..9846798f 100644 --- a/components/ui/status-badge.tsx +++ b/components/ui/status-badge.tsx @@ -18,7 +18,7 @@ export default function StatusBadge({ return (
diff --git a/hooks/use-file.tsx b/hooks/use-file.tsx index 026ffd42..2d8016ef 100644 --- a/hooks/use-file.tsx +++ b/hooks/use-file.tsx @@ -196,19 +196,32 @@ export const useFileUpload = ({ */ const upload = async (file?: File | null) => { if (!file) return false; - - // Perform the file upload - setIsUploading(true); - const form = FileUploadFormBuilder.new(filename); - form.file(file); - - // Check for success - const result = (await uploader(form.build())) as { success?: boolean }; - setResponse(result); - - if (!result.success) return; - if (!silent) alert("File uploaded successfully!"); - setIsUploading(false); + + try { + // Perform the file upload + setIsUploading(true); + const form = FileUploadFormBuilder.new(filename); + form.file(file); + + // Check for success + const result = (await uploader(form.build())) as { success?: boolean }; + setResponse(result); + + if (!result.success) { + if (!silent) alert("Upload failed."); + return false; + }; + + if (!silent) alert("File uploaded successfully!"); + return true; + } catch (error) { + console.error("Upload failed: ", error); + setResponse({ success: false }); + + if (!silent) alert("Upload failed. Please try again later."); + } finally { + setIsUploading(false); + } }; return {