diff --git a/app/hire/dashboard/applicant/page.tsx b/app/hire/dashboard/applicant/page.tsx index 925608e7..ef021098 100644 --- a/app/hire/dashboard/applicant/page.tsx +++ b/app/hire/dashboard/applicant/page.tsx @@ -4,6 +4,7 @@ import { statusMap } from "@/components/common/status-icon-map"; import ContentLayout from "@/components/features/hire/content-layout"; import { ApplicantPage } from "@/components/features/hire/dashboard/ApplicantPage"; import { type ActionItem } from "@/components/ui/action-item"; +import useApplicationActions from "@/hooks/use-application-actions"; import { useEmployerApplications } from "@/hooks/use-employer-api"; import { updateApplicationStatus, UserService } from "@/lib/api/services"; import { EmployerApplication } from "@/lib/db/db.types"; @@ -17,7 +18,17 @@ function ApplicantPageContent() { const jobId = searchParams.get("jobId"); const isDummyProfile = searchParams.get("dummy") === "1"; const [loading, setLoading] = useState(true); + const applications = useEmployerApplications(); + const { app_statuses } = useDbRefs(); + + const { + triggerAccept, + triggerReject, + triggerShortlist, + triggerArchive, + triggerDelete, + } = useApplicationActions(applications.review); const dummyApplication: EmployerApplication = { id: "dummy-super-application", @@ -76,8 +87,6 @@ function ApplicantPageContent() { otherApplications = []; } - const { app_statuses } = useDbRefs(); - useEffect(() => { const fetchUserData = async () => { if (isDummyProfile) { @@ -108,16 +117,37 @@ function ApplicantPageContent() { [], ); - const getStatuses = (applicationId: string) => { + const getStatuses = (application: EmployerApplication) => { return unique_app_statuses .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0) .map((status): ActionItem => { const uiProps = statusMap.get(status.id); + + const handleClick = () => { + switch (status.id) { + case 1: + triggerShortlist(application); + break; + case 4: + triggerAccept(application); + break; + case 5: + triggerDelete(application); + break; + case 6: + triggerReject(application); + break; + case 7: + triggerArchive(application); + break; + } + }; + return { id: status.id.toString(), label: status.name, icon: uiProps?.icon, - onClick: () => updateApplicationStatus(applicationId, status.id), + onClick: handleClick, destructive: uiProps?.destructive, }; }); @@ -129,10 +159,14 @@ function ApplicantPageContent() { { + if (userApplication) triggerArchive(userApplication); + }} + onDelete={() => { + if (userApplication) triggerDelete(userApplication); + }} /> diff --git a/app/student/applications/page.tsx b/app/student/applications/page.tsx index d1bdb613..06d92052 100644 --- a/app/student/applications/page.tsx +++ b/app/student/applications/page.tsx @@ -109,9 +109,13 @@ const ApplicationCard = ({ application }: { application: UserApplication }) => { challengeTitleFromJoin.trim().length > 0; const isUnavailable = !job?.is_active || job?.is_deleted; const canOpenListing = !!job?.id && !isUnavailable; - const statusLabel = to_app_status_name(application.status) ?? "Pending"; + const statusLabel = + application.status === 5 || application.status === 7 + ? "Closed" + : (to_app_status_name(application.status) ?? "Pending"); let statusBadgeType: "destructive" | "supportive" | "warning" = "warning"; - if (statusLabel === "Rejected") statusBadgeType = "destructive"; + if (statusLabel === "Rejected" || statusLabel === "Closed") + statusBadgeType = "destructive"; else if (statusLabel === "Accepted" || statusLabel === "Hired") statusBadgeType = "supportive"; else statusBadgeType = "warning"; diff --git a/app/student/fff/layout.tsx b/app/student/fff/layout.tsx deleted file mode 100644 index 18e797f2..00000000 --- a/app/student/fff/layout.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { Metadata } from "next"; - -export const metadata: Metadata = { - title: "BetterInternship x FFF: Startup Accelerator Intern", - description: - "Scout top AI-native builders, network deeply, and help scale the next startup accelerator.", - openGraph: { - title: "BetterInternship x FFF: Startup Accelerator Intern", - description: - "Scout top AI-native builders, network deeply, and help scale the next startup accelerator.", - url: "/fff", - siteName: "BetterInternship", - type: "website", - }, - twitter: { - card: "summary_large_image", - title: "BetterInternship x FFF: Startup Accelerator Intern", - description: - "Scout top AI-native builders, network deeply, and help scale the next startup accelerator.", - }, -}; - -export default function FFFLayout({ children }: { children: React.ReactNode }) { - return <>{children}; -} diff --git a/app/student/fff/opengraph-image.tsx b/app/student/fff/opengraph-image.tsx deleted file mode 100644 index 17dce097..00000000 --- a/app/student/fff/opengraph-image.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { ImageResponse } from "next/og"; - -export const size = { - width: 1200, - height: 630, -}; - -export const contentType = "image/png"; - -export default function OpenGraphImage() { - return new ImageResponse( -
-
- BetterInternship - x - Founders For Founders -
- -
-
- - Super Listing -
- -
- Scout. Network. Scale. -
- -
- Startup Accelerator Intern -
-
- -
- Scout top AI-native builders, network deeply, and help scale the next - startup accelerator. -
-
, - size, - ); -} diff --git a/app/student/fff/page.tsx b/app/student/fff/page.tsx deleted file mode 100644 index fa34edfe..00000000 --- a/app/student/fff/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function LegacyFFFRedirectPage() { - redirect("/super-listing/fff"); -} diff --git a/app/student/forms/page.tsx b/app/student/forms/page.tsx index 2e1b649c..26f4f3b6 100644 --- a/app/student/forms/page.tsx +++ b/app/student/forms/page.tsx @@ -17,6 +17,7 @@ import { useFormFilloutProcessPending, useFormFilloutProcessReader, } from "@/hooks/forms/filloutFormProcess"; +import { isProfileVerified } from "@/lib/profile"; /** * The forms page component - shows either history or generate based on form count @@ -38,10 +39,22 @@ export default function FormsPage() { if (!isAuthenticated()) { return; // Exit if not authenticated } + if (profile.isPending) { + return; + } + if (!isProfileVerified(profile.data)) { + router.push("/register/verify"); + return; + } - if (!profile.data?.department && !profile.isPending) - router.push("/profile/complete-profile"); - }, [isAuthenticated, profile.data?.department, profile.isPending, router]); + if (!profile.data?.department) router.push("/profile/complete-profile"); + }, [ + isAuthenticated, + profile.data, + profile.data?.department, + profile.isPending, + router, + ]); // Query 1: Check for updates (cheap query - just a timestamp) // TODO: Enable this later for smart cache invalidation diff --git a/app/student/layout.tsx b/app/student/layout.tsx index 5649a9e9..ace1685f 100644 --- a/app/student/layout.tsx +++ b/app/student/layout.tsx @@ -15,11 +15,32 @@ import MobileNavWrapper from "@/components/shared/mobile-nav-wrapper"; import { SonnerToaster } from "@/components/ui/sonner-toast"; import { ClientProcessesProvider } from "@betterinternship/components"; -const baseUrl = - process.env.NEXT_PUBLIC_CLIENT_URL || "https://betterinternship.com"; +const baseUrl = (() => { + const isProduction = process.env.NODE_ENV === "production"; + const fallbackBaseUrl = isProduction + ? "https://www.betterinternship.com" + : "https://dev.betterinternship.com"; + const rawConfiguredUrl = process.env.NEXT_PUBLIC_CLIENT_URL?.trim(); + const configuredUrl = rawConfiguredUrl + ? /^https?:\/\//i.test(rawConfiguredUrl) + ? rawConfiguredUrl + : `https://${rawConfiguredUrl}` + : undefined; + + // Prevent localhost OG/Twitter URLs so metadata always points to shareable domains. + if ( + configuredUrl && + /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(configuredUrl) + ) { + return fallbackBaseUrl; + } + + return configuredUrl || fallbackBaseUrl; +})().replace(/\/$/, ""); const ogImage = `${baseUrl}/student-preview.png`; export const metadata: Metadata = { + metadataBase: new URL(baseUrl), title: "BetterInternship", description: "Better Internships Start Here.", icons: { diff --git a/app/student/miro/layout.tsx b/app/student/miro/layout.tsx deleted file mode 100644 index 1fb05802..00000000 --- a/app/student/miro/layout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { Metadata } from "next"; - -const baseUrl = - process.env.NEXT_PUBLIC_CLIENT_URL || "https://betterinternship.com"; -const ogImageUrl = `${baseUrl}/miro-preview.png`; - -export const metadata: Metadata = { - title: "BetterInternship x Miro: Miro-thon!", - description: "Fight for an internship at Miro", - openGraph: { - images: [ - { - url: ogImageUrl, - width: 1200, - height: 630, - }, - ], - }, - twitter: { - images: [ogImageUrl], - }, -}; - -export default function MiroLayout({ - children, -}: { - children: React.ReactNode; -}) { - return <>{children}; -} diff --git a/app/student/miro/miro-icon.svg b/app/student/miro/miro-icon.svg deleted file mode 100644 index 6e54a665..00000000 --- a/app/student/miro/miro-icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/app/student/miro/page.tsx b/app/student/miro/page.tsx deleted file mode 100644 index 2a6c500a..00000000 --- a/app/student/miro/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function LegacyMiroRedirectPage() { - redirect("/super-listing/miro"); -} diff --git a/app/student/register/verify/page.tsx b/app/student/register/verify/page.tsx index f0836ee2..9aede37e 100644 --- a/app/student/register/verify/page.tsx +++ b/app/student/register/verify/page.tsx @@ -6,8 +6,6 @@ import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; -import { useModal } from "@/hooks/use-modal"; -import { SquareAsterisk } from "lucide-react"; import { InputOTP, InputOTPGroup, @@ -17,43 +15,63 @@ import { AlertTriangle, Repeat } from "lucide-react"; import { useProfileData } from "@/lib/api/student.data.api"; import { AuthService } from "@/lib/api/services"; import { useQueryClient } from "@tanstack/react-query"; +import { useAuthContext } from "@/lib/ctx-auth"; +import { Loader } from "@/components/ui/loader"; +import { toast } from "sonner"; +import { toastPresets } from "@/components/ui/sonner-toast"; export default function VerifyPage() { const router = useRouter(); + const { redirectIfNotLoggedIn } = useAuthContext(); const nextUrl = "/search"; const profile = useProfileData(); + const [mounted, setMounted] = useState(false); - const deciding = profile.data === undefined; + useEffect(() => { + setMounted(true); + }, []); + + redirectIfNotLoggedIn(); // Redirect only after we know the profile state useEffect(() => { - if (deciding) return; + if (profile.isPending) return; if (profile.data?.is_verified) router.replace(nextUrl); - }, [deciding, profile.data?.is_verified, router]); + }, [profile.isPending, profile.data?.is_verified, router]); - // Prevent any flash - if (deciding || profile.data?.is_verified) return null; + // Prevent hydration mismatch when client restores persisted query cache. + // Server render and first client render both return null. + if (!mounted) return null; + + // Wait for profile and auth checks; unauthenticated users are redirected + if (profile.isPending) return Loading...; + if (!profile.data || profile.data?.is_verified) return null; return ( -
-
+
+
BetterInternship -

Verify your school email

+

+ Verify your school email +

+

+ One last step to activate your student account. +

-
- +
+ router.replace(nextUrl)} /> -
+
Having trouble? Make sure you typed your .edu.ph email correctly and check your spam folder.
@@ -78,6 +96,12 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) { const [activating, setActivating] = useState(false); const [countdown, setCountdown] = useState(0); + useEffect(() => { + if (!eduEmail && profile.data?.edu_verification_email) { + setEduEmail(profile.data.edu_verification_email); + } + }, [eduEmail, profile.data?.edu_verification_email]); + useEffect(() => { if (!eduEmail?.trim()) return setIsEmailValid(false); if (!eduEmail.endsWith(".edu.ph")) return setIsEmailValid(false); @@ -90,41 +114,47 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) { setActivating(true); setOtpError(""); AuthService.activate(eduEmail, otp) - .then(async (response: any) => { + .then(async (response) => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); - if (response?.success) { + if (response?.success === true) { onFinish(); } else { - setOtpError(response?.message?.trim() || "OTP not valid."); + setOtpError( + response?.message?.trim() || + response?.error?.trim() || + "OTP not valid.", + ); } }) - .catch(() => setOtpError("Couldn’t verify your code. Try again.")) + .catch(() => setOtpError("Couldn't verify your code. Try again.")) .finally(() => setActivating(false)); - }, [otp, eduEmail, onFinish]); - - const { - open: openOTPModal, - close: closeOTPModal, - Modal: OTPModal, - } = useModal("otp-modal"); + }, [otp, eduEmail, onFinish, queryClient]); const requestOTP = () => { if (!isEmailValid || sending || isCoolingDown) return; setSending(true); setOtpError(""); AuthService.requestActivation(eduEmail) - .then((response: any) => { - if (response?.message && response?.success === false) { - alert(response.message); + .then((response) => { + if (response?.success !== true) { + console.log("OTP request failed:", response); + const err = + response?.message?.trim() || + response?.error?.trim() || + "Couldn't send OTP. Try again."; + setOtpError(err); return; } - openOTPModal(); + toast.success( + "OTP sent. Check your inbox for the 6-digit code.", + toastPresets.success, + ); setSent(true); setIsCoolingDown(true); setCountdown(60); }) - .catch(() => setOtpError("Couldn’t send OTP. Try again.")) + .catch(() => setOtpError("Couldn't send OTP. Try again.")) .finally(() => setSending(false)); }; @@ -141,40 +171,46 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) { <>
-

+

Enter your school email to receive a one-time passcode (OTP).

-
+
setEduEmail(e.currentTarget.value)} /> -
+
{sent && ( - - - - - - - - - - +
+
+ Enter the 6-digit code sent to your email +
+ + + + + + + + + + +
)}
{otpError && ( -
+
{otpError}
@@ -182,14 +218,18 @@ function StepActivateOTP({ onFinish }: { onFinish: () => void }) {
{activating && ( - Activating account... + + Activating account... + )}
-
+
- - -
-
-
- - - - - - -
-
-

Check your inbox for a 6-digit code.

- OTP expires in 10 minutes. -
-
-
- -
-
-
); } diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index 4a5d7aeb..676410e0 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -7,17 +7,8 @@ import { Button } from "@/components/ui/button"; import { useProfileData, useJobData } from "@/lib/api/student.data.api"; import { useDbRefs } from "@/lib/db/use-refs"; import { useModalRef } from "@/hooks/use-modal"; -import ReactMarkdown from "react-markdown"; import { Loader } from "@/components/ui/loader"; -import { - EmployerMOA, - JobType, - JobSalary, - JobMode, - JobHead, - JobApplicationRequirements, - JobDetails, -} from "@/components/shared/jobs"; +import { JobDetails } from "@/components/shared/jobs"; import { Card } from "@/components/ui/card"; import { ApplySuccessModal } from "@/components/modals/ApplySuccessModal"; import { PageError } from "@/components/ui/error"; @@ -44,7 +35,6 @@ export default function JobPage() { const applicationActions = useApplicationActions(); const profile = useProfileData(); - const { universities } = useDbRefs(); const { isAuthenticated } = useAuthContext(); const goProfile = useCallback(() => { @@ -107,9 +97,7 @@ export default function JobPage() { {job.data && (
- + + />
diff --git a/app/student/search/page.tsx b/app/student/search/page.tsx index bb34d9e5..6a6e9a0e 100644 --- a/app/student/search/page.tsx +++ b/app/student/search/page.tsx @@ -20,7 +20,11 @@ import { ApplySuccessModal } from "@/components/modals/ApplySuccessModal"; import { JobModal } from "@/components/modals/JobModal"; import { useMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; -import { isProfileBaseComplete, isProfileResume } from "@/lib/profile"; +import { + isProfileBaseComplete, + isProfileResume, + isProfileVerified, +} from "@/lib/profile"; import { useRouter } from "next/navigation"; import { SaveJobButton } from "@/components/features/student/job/save-job-button"; import { ApplyToJobButton } from "@/components/features/student/job/apply-to-job-button"; @@ -193,6 +197,9 @@ export default function SearchPage() { window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/auth/google`; return; } + if (!isProfileVerified(profile.data)) { + return router.push("/register/verify"); + } if ( !isProfileResume(profile.data) || diff --git a/app/student/super-listing/anteriore/components/ApplyPanel.tsx b/app/student/super-listing/anteriore/components/ApplyPanel.tsx index 060aff62..58e59aa5 100644 --- a/app/student/super-listing/anteriore/components/ApplyPanel.tsx +++ b/app/student/super-listing/anteriore/components/ApplyPanel.tsx @@ -22,6 +22,8 @@ import type { AnterioreSubmissionForm, SubmissionStep } from "./types"; type ApplyPanelProps = { form: AnterioreSubmissionForm; + submissionsDisabled?: boolean; + submissionsDisabledMessage?: string; submissionStep: SubmissionStep; hasSubmitted: boolean; submittedEmail: string; @@ -43,6 +45,8 @@ type ApplyPanelProps = { export function ApplyPanel({ form, + submissionsDisabled = false, + submissionsDisabledMessage = "Submissions are currently closed.", submissionStep, hasSubmitted, submittedEmail, @@ -202,6 +206,11 @@ export function ApplyPanel({ ) : (
void onSubmit(e)}> + {submissionsDisabled ? ( +
+ {submissionsDisabledMessage} +
+ ) : null} {submissionStep === 1 && ( <>
@@ -210,6 +219,7 @@ export function ApplyPanel({