diff --git a/.gitignore b/.gitignore index 5780363ba..25a650dc0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ CLAUDE.md AGENTS.md .claude/ .claudeignore + +# Postman files +.postman/ +postman/ diff --git a/README.md b/README.md index a22efee5a..d5aa256cb 100644 --- a/README.md +++ b/README.md @@ -16,4 +16,3 @@ npm run dev ```bash npm run test ``` - diff --git a/app/hire/%5F%5F/page.tsx b/app/hire/%5F%5F/page.tsx index 68c368842..f7a374863 100644 --- a/app/hire/%5F%5F/page.tsx +++ b/app/hire/%5F%5F/page.tsx @@ -15,7 +15,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/app/hire/__/page.tsx b/app/hire/__/page.tsx index 68c368842..f7a374863 100644 --- a/app/hire/__/page.tsx +++ b/app/hire/__/page.tsx @@ -15,7 +15,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/app/hire/allowLanding.tsx b/app/hire/allowLanding.tsx index 79c15e32f..4e2110433 100644 --- a/app/hire/allowLanding.tsx +++ b/app/hire/allowLanding.tsx @@ -16,10 +16,14 @@ export default function AllowLanding({ const hideHeader = isStudentLanding || isAuthRoute; return ( -
+ <> {!hideHeader && } -
{children}
- {!isStudentLanding &&
+
{children}
+ {!isStudentLanding && ( +
+
+ )} + ); } diff --git a/app/hire/auth/secure-access/[employerUserId]/[hash]/route.ts b/app/hire/auth/secure-access/[employerUserId]/[hash]/route.ts new file mode 100644 index 000000000..87e27d060 --- /dev/null +++ b/app/hire/auth/secure-access/[employerUserId]/[hash]/route.ts @@ -0,0 +1,24 @@ +/** + * Forwards minted employer magic links to the API endpoint that consumes the + * keyring and sets the employer auth cookies. + */ +export async function GET( + request: Request, + { + params, + }: { params: Promise<{ employerUserId: string; hash: string }> }, +) { + const { employerUserId, hash } = await params; + const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, ''); + + if (!apiBaseUrl) { + return Response.redirect(new URL('/login', request.url), 307); + } + + const apiUrl = new URL( + `${apiBaseUrl}/auth/secure-access/${encodeURIComponent(employerUserId)}/${encodeURIComponent(hash)}`, + ); + apiUrl.search = new URL(request.url).search; + + return Response.redirect(apiUrl, 307); +} diff --git a/app/hire/authctx.tsx b/app/hire/authctx.tsx index f7724eaf8..40687a3fe 100644 --- a/app/hire/authctx.tsx +++ b/app/hire/authctx.tsx @@ -111,9 +111,6 @@ export const AuthContextProvider = ({ await Promise.all([ queryClient.invalidateQueries({ queryKey: ["my-employer-profile"] }), - queryClient.invalidateQueries({ - queryKey: ["my-employer-conversations"], - }), queryClient.invalidateQueries({ queryKey: ["me"] }), queryClient.invalidateQueries({ queryKey: ["my-employer-team"] }), ]); diff --git a/app/hire/dashboard/applicant/page.tsx b/app/hire/dashboard/applicant/page.tsx index c0396d4dd..34c1cb994 100644 --- a/app/hire/dashboard/applicant/page.tsx +++ b/app/hire/dashboard/applicant/page.tsx @@ -26,7 +26,8 @@ function ApplicantPageContent() { (a) => applicationId === a.id, ); const otherUserApplications = applications?.employer_applications.filter( - (a) => a.user_id === userApplication?.user_id, + (a) => + a.user_id === userApplication?.user_id && a.id !== userApplication?.id, ); const jobId = userApplication?.job_id; const userId = userApplication?.user_id; @@ -68,8 +69,11 @@ function ApplicantPageContent() { ); const getStatuses = () => { + const statusOrder = [0, 1, 4, 6]; + return unique_app_statuses - .filter((status) => status.id !== 7 && status.id !== 5 && status.id !== 0) + .filter((status) => status.id !== 7 && status.id !== 5) + .sort((a, b) => statusOrder.indexOf(a.id) - statusOrder.indexOf(b.id)) .map((status): DropdownMenuItem => { const config = DB_STATUS_MAP[status.id]; @@ -94,18 +98,9 @@ function ApplicantPageContent() { application={userApplication} statuses={getStatuses()} userApplications={otherUserApplications} - onArchive={() => { - if (!userApplication) return; - if (userApplication.visibility === "archived") { - triggerAction("UNARCHIVE", [userApplication]); - } else { - triggerAction("ARCHIVE", [userApplication]); - } - }} - onDelete={() => { - if (!userApplication) return; - if (userApplication) triggerAction("DELETE", [userApplication]); - }} + onArchive={() => + userApplication && triggerAction("ARCHIVE", [userApplication]) + } /> diff --git a/app/hire/dashboard/manage/page.tsx b/app/hire/dashboard/manage/page.tsx index 79e5715ad..17d791f41 100644 --- a/app/hire/dashboard/manage/page.tsx +++ b/app/hire/dashboard/manage/page.tsx @@ -39,7 +39,7 @@ function ManageContent() { return ( <> - + {profile.data?.is_verified === false ? (
diff --git a/app/hire/dashboard/page.tsx b/app/hire/dashboard/page.tsx index f4aedd1b7..317aa17d0 100644 --- a/app/hire/dashboard/page.tsx +++ b/app/hire/dashboard/page.tsx @@ -21,6 +21,7 @@ import { import { StatusNotice } from "@betterinternship/components/status-notice"; import { Button } from "@betterinternship/components"; import { Pause, Plus } from "lucide-react"; +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- retained with the commented banner import { IomPartnershipCta } from "@/components/features/hire/iom-partnership-cta"; const NORMAL_LISTING_CREATE_PATH = "/listings/create"; @@ -76,6 +77,7 @@ function MagicLinkContinuation({ } function DashboardContent() { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- retained with the commented banner const { isAuthenticated, redirectIfNotLoggedIn, user } = useAuthContext(); const router = useRouter(); const profile = useProfile(); @@ -108,12 +110,14 @@ function DashboardContent() { return ( + {/* MOA partnership banner temporarily disabled. {profile.data && ( )} + */} {m.email} + {!m.receives_applicant_digest && ( + + )} ))} diff --git a/app/hire/god/moa-uploads/page.tsx b/app/hire/god/moa-uploads/page.tsx index 4869b2d84..f7aa30aea 100644 --- a/app/hire/god/moa-uploads/page.tsx +++ b/app/hire/god/moa-uploads/page.tsx @@ -14,29 +14,33 @@ import { useApproveMoaUpload, useRejectMoaUpload, useGodUniversities, + getGodMoaDocumentUrl, MoaUpload, } from "@/lib/api/god.api"; import { Paginator } from "@/components/ui/paginator"; +import { PDFPreview } from "@/components/shared/pdf-preview"; const PAGE_SIZE = 20; -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; - function ModalShell({ open, onClose, title, children, + className, }: { open: boolean; onClose: () => void; title: string; children: React.ReactNode; + className?: string; }) { if (!open) return null; return (
-
+

{title}

+ setPreviewUrl(null)} + title="MOA Preview" + className="max-w-6xl" + > + {previewUrl && } + + setApproveTarget(null)} diff --git a/app/hire/god/stats/page.tsx b/app/hire/god/stats/page.tsx index 67a1e5f80..3f933d276 100644 --- a/app/hire/god/stats/page.tsx +++ b/app/hire/god/stats/page.tsx @@ -1,6 +1,11 @@ "use client"; -import { useEmployerLoginMetrics, useWeeklyStats } from "@/lib/api/god.api"; +import { + useEmployerLoginMetrics, + useRefreshEmployerLoginMetrics, + useWeeklyStats, +} from "@/lib/api/god.api"; +import { RefreshCw } from "lucide-react"; import { useMemo } from "react"; function WeeklyChart({ data }: { data: { week_start: string; applications: number }[] }) { @@ -57,7 +62,9 @@ function WeeklyChart({ data }: { data: { week_start: string; applications: numbe export default function GodStatsPage() { const { data, isFetching } = useWeeklyStats(); - const { data: loginMetricsData } = useEmployerLoginMetrics(); + const { data: loginMetricsData, isLoading: isLoginMetricsLoading } = + useEmployerLoginMetrics(); + const refreshLoginMetrics = useRefreshEmployerLoginMetrics(); const stats = data?.stats ?? []; const tableStats = useMemo(() => [...stats].reverse(), [stats]); @@ -69,38 +76,66 @@ export default function GodStatsPage() { Weekly Application Stats - {loginMetrics && ( -
-

+
+
+

Employer Login Stats

-
- refreshLoginMetrics.mutate()} + disabled={refreshLoginMetrics.isPending} + className="inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50" + > + - - - -
-

- Updated nightly: {new Date(loginMetrics.cached_at).toLocaleString()} + {refreshLoginMetrics.isPending ? "Refreshing…" : "Refresh stats"} + +

+ {loginMetrics ? ( + <> +
+ + + + +
+

+ Updated: {new Date(loginMetrics.cached_at).toLocaleString()} +

+ + ) : ( +

+ {isLoginMetricsLoading + ? "Loading employer login stats..." + : "Employer login stats are unavailable."}

-
- )} + )} + {(refreshLoginMetrics.isError || + refreshLoginMetrics.data?.success === false) && ( +

+ Could not refresh employer login stats. +

+ )} +

{isFetching && !stats.length && (

Loading...

diff --git a/app/hire/l/[slug]/route.ts b/app/hire/l/[slug]/route.ts new file mode 100644 index 000000000..06bc3d85c --- /dev/null +++ b/app/hire/l/[slug]/route.ts @@ -0,0 +1,22 @@ +/** + * Resolves employer-portal short links using the shared links API. + * Unknown slugs and API failures return the visitor to the employer home page. + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + const origin = new URL(request.url).origin; + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_URL}/links/${slug}`, + { cache: "no-store" }, + ); + const data = (await res.json()) as { url?: string | null }; + return Response.redirect(data?.url ?? origin, 307); + } catch { + return Response.redirect(origin, 307); + } +} diff --git a/app/hire/layout.tsx b/app/hire/layout.tsx index cfed74315..168f1b335 100644 --- a/app/hire/layout.tsx +++ b/app/hire/layout.tsx @@ -94,13 +94,11 @@ const HTMLContent = ({ - -
-
- {children} -
+
+
+ {children}
- +
diff --git a/app/hire/listings/details/page.tsx b/app/hire/listings/details/page.tsx index c3563a7fc..55465326c 100644 --- a/app/hire/listings/details/page.tsx +++ b/app/hire/listings/details/page.tsx @@ -50,7 +50,7 @@ function JobDetailsPageRouteContent() { onJobUpdate={updateJob} backHref={`/dashboard/manage?jobId=${jobId}`} /> -
+
diff --git a/app/hire/listings/edit/page.tsx b/app/hire/listings/edit/page.tsx index 7a49cc44b..75773aaa4 100644 --- a/app/hire/listings/edit/page.tsx +++ b/app/hire/listings/edit/page.tsx @@ -1,15 +1,20 @@ "use client"; -import EditJobPage from "@/components/features/hire/listings/editJob"; +import EditJobPage, { + type EditJobPageHandle, +} from "@/components/features/hire/listings/editJob"; import JobHeader from "@/components/features/hire/dashboard/JobHeader"; import { Loader } from "@/components/ui/loader"; import { JobLoadError } from "@/components/features/hire/job-load-error"; import { PageContainer } from "@betterinternship/components/page-header"; +import { Button } from "@betterinternship/components"; +import { useModalRegistry } from "@/components/modals/modal-registry"; import { useJob } from "@/hooks/use-employer-api"; +import { useAppContext } from "@/lib/ctx-app"; import { JobService } from "@/lib/api/services"; import { UpdateJobChallengeListingPayload } from "@/lib/db/db.types"; -import { useSearchParams } from "next/navigation"; -import { Suspense, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useRef, useState } from "react"; export default function EditJobPageRoute() { return ( @@ -20,11 +25,16 @@ export default function EditJobPageRoute() { } function EditJobPageRouteContent() { + const router = useRouter(); const searchParams = useSearchParams(); const jobId = searchParams.get("jobId"); const { job, loading, error, notFound, refetch } = useJob(jobId); const [isEditing, setIsEditing] = useState(false); const [saving, setSaving] = useState(false); + const [isMissing, setIsMissing] = useState(true); + const { isMobile } = useAppContext(); + const modalRegistry = useModalRegistry(); + const editJobRef = useRef(null); const updateJob = async ( job_id: string, @@ -46,6 +56,33 @@ function EditJobPageRouteContent() { } }; + const openDiscardModal = () => + modalRegistry.discardEdit.open({ + onConfirm: () => router.push(`/dashboard/manage?jobId=${jobId}`), + }); + + const desktopEditActions = ( +
+ + +
+ ); + if (loading) { return ( @@ -68,14 +105,20 @@ function EditJobPageRouteContent() { return ( <> - + ); diff --git a/app/hire/register/verify/page.tsx b/app/hire/register/verify/page.tsx index fae0ca192..2ca2414af 100644 --- a/app/hire/register/verify/page.tsx +++ b/app/hire/register/verify/page.tsx @@ -14,6 +14,8 @@ import { useAuthContext } from "../../authctx"; import { HireAuthShell } from "@/components/features/hire/hire-auth-shell"; type RegistrationProfile = Record; +const OTP_SENT_AT_KEY = "hire-registration-otp-sent-at"; +const OTP_COOLDOWN_SECONDS = 60; export default function VerifyHireRegistrationPage() { const router = useRouter(); @@ -48,6 +50,7 @@ export default function VerifyHireRegistrationPage() { const completeActivation = async () => { sessionStorage.removeItem("hire-registration-email"); + sessionStorage.removeItem(OTP_SENT_AT_KEY); await refreshAuthentication(); router.replace("/dashboard"); }; @@ -61,6 +64,7 @@ export default function VerifyHireRegistrationPage() { otpInputProps, requestOtp, sending, + startCooldown, } = useOtpVerification({ email: email.trim().toLowerCase(), requestOtpAction: (address) => @@ -75,6 +79,19 @@ export default function VerifyHireRegistrationPage() { }, }); + useEffect(() => { + if (!hasSentCode) return; + + const sentAt = Number(sessionStorage.getItem(OTP_SENT_AT_KEY)); + if (!Number.isFinite(sentAt)) return; + + const remaining = Math.ceil( + (sentAt + OTP_COOLDOWN_SECONDS * 1000 - Date.now()) / 1000, + ); + if (remaining > 0) startCooldown(remaining); + else sessionStorage.removeItem(OTP_SENT_AT_KEY); + }, [hasSentCode, startCooldown]); + const submitCode = async () => { const result = await activateOtp(otpInputProps.value, { failureMessage: "Verification code not valid.", @@ -117,6 +134,7 @@ export default function VerifyHireRegistrationPage() { sessionStorage.removeItem("hire-registration-profile"); sessionStorage.setItem("hire-registration-email", normalizedEmail); + sessionStorage.setItem(OTP_SENT_AT_KEY, String(Date.now())); setAccountCreated(true); setHasSentCode(true); } catch { @@ -131,6 +149,7 @@ export default function VerifyHireRegistrationPage() { failureMessage: "Couldn't send verification code. Try again.", }); if (result?.success) { + sessionStorage.setItem(OTP_SENT_AT_KEY, String(Date.now())); sessionStorage.setItem("hire-registration-email", normalizedEmail); setHasSentCode(true); } @@ -173,6 +192,7 @@ export default function VerifyHireRegistrationPage() { setter={(value) => { setEmail(value); setHasSentCode(false); + sessionStorage.removeItem(OTP_SENT_AT_KEY); setRegistrationError(""); }} maxLength={80} diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 000000000..e8e635d7d --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,20 @@ +import { baseUrl } from "@/lib/site-url"; +import type { MetadataRoute } from "next"; +import { headers } from "next/headers"; + +export default async function robots(): Promise { + const host = (await headers()).get("host") ?? ""; + + if (host.startsWith("hire.")) { + return { rules: { userAgent: "*", disallow: ["/dashboard"] } }; + } + + return { + rules: { + userAgent: "*", + allow: "/", + disallow: ["/profile", "/applications", "/saved", "/forms"], + }, + sitemap: `${baseUrl}/sitemap.xml`, + }; +} diff --git a/app/student/%5F%5F/page.tsx b/app/student/%5F%5F/page.tsx index 837d0e62a..651998fce 100644 --- a/app/student/%5F%5F/page.tsx +++ b/app/student/%5F%5F/page.tsx @@ -15,7 +15,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/app/student/__/page.tsx b/app/student/__/page.tsx index 0dab33fcf..c890c5f58 100644 --- a/app/student/__/page.tsx +++ b/app/student/__/page.tsx @@ -16,7 +16,6 @@ const InternalSetupPage = () => { await queryClient.invalidateQueries({ queryKey: ["my-profile"] }); await queryClient.invalidateQueries({ queryKey: ["my-applications"] }); await queryClient.invalidateQueries({ queryKey: ["my-saved-jobs"] }); - await queryClient.invalidateQueries({ queryKey: ["my-conversations"] }); await queryClient.invalidateQueries({ queryKey: ["my-forms"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-templates"] }); await queryClient.invalidateQueries({ queryKey: ["my-form-template"] }); diff --git a/app/student/allowLanding.tsx b/app/student/allowLanding.tsx index 7adf85060..de33feb1d 100644 --- a/app/student/allowLanding.tsx +++ b/app/student/allowLanding.tsx @@ -2,6 +2,7 @@ import { usePathname } from "next/navigation"; import StudentAppHeader from "@/components/features/student/app-header"; +import { Footer } from "@/components/shared/footer"; import { Suspense } from "react"; export default function AllowLanding({ @@ -19,16 +20,26 @@ export default function AllowLanding({ if (hideSharedHeader) { return ( -
{children}
+
+ {children} + {!isStudentLanding && ( +
+
+
+ )} +
); } return ( -
+ <> -
{children}
-
+
{children}
+
+
+
+ ); } diff --git a/app/student/applications/page.tsx b/app/student/applications/page.tsx index de37b75d7..c007c3299 100644 --- a/app/student/applications/page.tsx +++ b/app/student/applications/page.tsx @@ -169,7 +169,7 @@ function MyJobsPageInner() {
- + Applications @@ -426,9 +426,17 @@ const ApplicationCard = ({ 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"; - let statusBadgeType: "destructive" | "supportive" | "warning" = "warning"; - if (statusLabel === "Rejected") statusBadgeType = "destructive"; + // A still-pending application whose listing has since closed reads as + // "Pending" forever otherwise — no stored state needed, since is_active/ + // is_deleted already ride along on application.job (plan D7). + const isClosedWhilePending = application.status === 0 && isUnavailable; + const statusLabel = isClosedWhilePending + ? "Listing closed" + : (to_app_status_name(application.status) ?? "Pending"); + let statusBadgeType: "default" | "destructive" | "supportive" | "warning" = + "warning"; + if (isClosedWhilePending) statusBadgeType = "default"; + else if (statusLabel === "Rejected") statusBadgeType = "destructive"; else if (statusLabel === "Accepted" || statusLabel === "Hired") statusBadgeType = "supportive"; else statusBadgeType = "warning"; diff --git a/app/student/companies/sofi-ai/og/route.tsx b/app/student/companies/sofi-ai/og/route.tsx index 914630cfd..70398c9f6 100644 --- a/app/student/companies/sofi-ai/og/route.tsx +++ b/app/student/companies/sofi-ai/og/route.tsx @@ -2,9 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { ImageResponse } from "next/og"; -export const contentType = "image/png"; - -export const size = { +const size = { width: 1200, height: 630, }; diff --git a/app/student/companies/sofi-ai/page.tsx b/app/student/companies/sofi-ai/page.tsx index 60fd7d7c0..3c85510af 100644 --- a/app/student/companies/sofi-ai/page.tsx +++ b/app/student/companies/sofi-ai/page.tsx @@ -739,16 +739,6 @@ function MediaSpotlight() { function FeaturedInternship() { const featuredListings = [ - { - href: "/super-listing/sofi-ai", - title: "UI/UX Intern", - closed: true, - icon: ( - - </> - - ), - }, { href: "/super-listing/sofi-ai-marketing", title: "Marketing Intern", diff --git a/app/student/forms/page.tsx b/app/student/forms/page.tsx index 534b07ba9..91c808efb 100644 --- a/app/student/forms/page.tsx +++ b/app/student/forms/page.tsx @@ -77,19 +77,6 @@ export default function FormsPage() { router, ]); - // Query 1: Check for updates (cheap query - just a timestamp) - // TODO: Enable this later for smart cache invalidation - // const { data: updateInfo } = useQuery({ - // queryKey: ["form-templates-last-updated"], - // queryFn: () => FormService.getFormTemplatesLastUpdated(), - // }); - - // console.log("Form templates last updated info:", updateInfo); - - // Query 2: Fetch full form data only if version changes - // The queryKey includes the version, so React Query treats it as a new query when version changes - // When re-enabling the smart update check, add updateInfo?.version back to queryKey - // and change enabled to: enabled: !!updateInfo const { data: { formTemplates, formGroupDescription } = {}, isLoading } = useQuery({ queryKey: ["my-form-templates"], @@ -98,7 +85,6 @@ export default function FormsPage() { staleTime: FORM_TEMPLATES_STALE_TIME, gcTime: FORM_TEMPLATES_GC_TIME, refetchOnWindowFocus: true, // Refetch when user switches back to tab - // enabled: !!updateInfo, // Only fetch after we have update info }); // ? I think I can abstract this somehow in the future diff --git a/app/student/layout.tsx b/app/student/layout.tsx index 6dfa06026..76815a890 100644 --- a/app/student/layout.tsx +++ b/app/student/layout.tsx @@ -14,29 +14,8 @@ import { SonnerToaster } from "@/components/ui/sonner-toast"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AppMQJobsProvider } from "@/components/providers/mq-jobs-provider"; import { FilloutJobsProvider } from "@/hooks/forms/filloutFormProcess"; +import { baseUrl } from "@/lib/site-url"; -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}/og.png`; export const metadata: Metadata = { @@ -81,7 +60,7 @@ export const viewport: Viewport = { * * @component */ -export const RootLayout = async ({ +const RootLayout = async ({ children, }: Readonly<{ children: React.ReactNode; @@ -119,14 +98,12 @@ const HTMLContent = ({ - -
-
- {children} -
- +
+
+ {children}
- + +
diff --git a/app/student/register/page.tsx b/app/student/register/page.tsx index 9d81a67fd..9be3bf540 100644 --- a/app/student/register/page.tsx +++ b/app/student/register/page.tsx @@ -26,7 +26,7 @@ export interface FormInputs { degree?: string; } -export function RegisterPageContent() { +function RegisterPageContent() { const refs = useDbRefs(); const auth = useAuthContext(); const router = useRouter(); @@ -67,13 +67,15 @@ export function RegisterPageContent() { // skip main register page if the user is already registered. useEffect(() => { if (step === 1 && auth.isAuthenticated()) { + if (submitting) return; + if (skipOtpStep) { finishRegistration(); } else { setStep(2); } } - }, [step, auth, router, skipOtpStep]); + }, [step, auth, router, skipOtpStep, submitting]); // Direct visits to /register (no OAuth pass, so no reg cookie) can never // register — treat them as if they clicked the sign-in button instead. @@ -121,7 +123,6 @@ export function RegisterPageContent() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const regForm = useForm({ @@ -141,7 +142,7 @@ export function RegisterPageContent() { */ const handleSubmit = (values: FormInputs) => { setSubmitting(true); - const shouldSkipOtp = skipOtpStep || isNoUniversity(values.university); + const shouldSkipOtpForUniversity = isNoUniversity(values.university); // Check for missing fields if (!values.first_name?.trim()) { @@ -188,7 +189,9 @@ export function RegisterPageContent() { setSubmitting(false); - if (shouldSkipOtp) { + const autoVerified = Boolean(response?.user?.edu_verification_email); + + if (shouldSkipOtpForUniversity || autoVerified) { finishRegistration(); return; } diff --git a/app/student/search/[job_id]/JobPageView.tsx b/app/student/search/[job_id]/JobPageView.tsx new file mode 100644 index 000000000..fab214ac4 --- /dev/null +++ b/app/student/search/[job_id]/JobPageView.tsx @@ -0,0 +1,202 @@ +"use client"; + +import React, { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, TriangleAlert, X } from "lucide-react"; +import { + Button, + PageContainer, + Card, + StatusNotice, +} from "@betterinternship/components"; +import { useProfileData, useJobData } from "@/lib/api/student.data.api"; +import { useModalRef } from "@/hooks/use-modal"; +import { useMobile } from "@/hooks/use-mobile"; +import { Loader } from "@/components/ui/loader"; +import { JobDetails } from "@/components/shared/jobs"; +import { ApplySuccessModal } from "@/components/modals/ApplySuccessModal"; +import { PageError } from "@/components/ui/error"; +import { SaveJobButton } from "@/components/features/student/job/save-job-button"; +import { ApplyToJobButton } from "@/components/features/student/job/apply-to-job-button"; +import { useApplicationActions } from "@/lib/api/student.actions.api"; +import { ShareJobButton } from "@/components/features/student/job/share-job-button"; +import type { ApplyPayload } from "@/components/modals/components/ApplyModal"; +import type { Job } from "@/lib/db/db.types"; +import { toast } from "sonner"; + +/** + * Job page interactive body. Rendered by the server + * page.tsx with the job already fetched (initialJob) so the details, share/ + * save/apply actions, and modals hydrate from real data instead of a + * client-only fetch. + */ +export function JobPageView({ + jobId, + initialJob, +}: { + jobId: string; + initialJob: Job; +}) { + const router = useRouter(); + + // check if user has in-app history to go back to, otherwise go to search page. + const handleBack = useCallback(() => { + const hasInAppHistory = (window.history.state?.idx ?? 0) > 0; + if (hasInAppHistory) router.back(); + else router.push("/search"); + }, [router]); + const job = useJobData(jobId, { initialData: initialJob }); + const [isActionsSheetOpen, setIsActionsSheetOpen] = useState(false); + const { isMobile } = useMobile(); + const applySuccessModalRef = useModalRef(); + const applicationActions = useApplicationActions(); + + const profile = useProfileData(); + + const handleApply = useCallback( + async ({ resumeId }: ApplyPayload) => { + if (!job.data?.id || !resumeId) return; + + const response = await applicationActions.create.mutateAsync({ + job_id: job.data.id, + resume_id: resumeId, + }); + + if (response.message) { + toast.error(response.message); + return; + } + + applySuccessModalRef.current?.open(); + }, + [applicationActions.create, job.data], + ); + + if (job.error && !job.data) + return ( + + ); + + if (!job || (!job.data && !job.isPending)) { + return ( +
+
+ + + Back + + } + variant="destructive" + /> +
+
+ ); + } + + return ( + <> + {/* Desktop and Mobile Layout */} +
+ {job.isPending ? ( + Loading job details... + ) : ( +
+
+
+
+ +
+ {job.data && } + {job.data && !job.data.hibernating && ( + <> + + + + )} +
+
+
+
+ + {job.data?.id && ( + + {/* Job Header Card */} + + {/* Job Details Grid */} + + + + )} + + {isMobile && isActionsSheetOpen && ( +
+ +
+ {job.data?.id && ( + setIsActionsSheetOpen(false)} + /> + )} +
+
+ )} +
+ )} +
+ + + + ); +} diff --git a/app/student/search/[job_id]/layout.tsx b/app/student/search/[job_id]/layout.tsx index 515e3119c..8936d91c3 100644 --- a/app/student/search/[job_id]/layout.tsx +++ b/app/student/search/[job_id]/layout.tsx @@ -1,20 +1,6 @@ import type { Metadata } from "next"; import { fetchJobPreview } from "@/lib/api/job-preview.server"; - -/** - * job.description is Markdown (rendered via react-markdown — see - * MarkdownBlock in components/shared/jobs.tsx), not HTML, so a chat preview - * needs the syntax stripped or it shows raw `#`/`*`/`[]` markup. - */ -const stripMarkdown = (text: string): string => - text - .replace(/```[\s\S]*?```/g, " ") - .replace(/`([^`]+)`/g, "$1") - .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/[#>*_~-]+/g, " ") - .replace(/\s+/g, " ") - .trim(); +import { markdownToPlainText } from "@/lib/utils/markdown-utils"; /** * Server-side metadata for a job page — the crawler-visible reason a `/l/` @@ -43,13 +29,15 @@ export async function generateMetadata({ const title = `${job.title} at ${job.employer?.name ?? "BetterInternship"}`; const description = job.description - ? stripMarkdown(job.description).slice(0, 160) + ? markdownToPlainText(job.description).slice(0, 160) : undefined; const image = `/search/og/${job_id}`; return { title, description, + alternates: { canonical: `/search/${job_id}` }, + ...(job.is_unlisted ? { robots: { index: false, follow: true } } : {}), openGraph: { title, description, images: [image], type: "website" }, twitter: { card: "summary_large_image", diff --git a/app/student/search/[job_id]/page.tsx b/app/student/search/[job_id]/page.tsx index d0a52e77a..758854d5f 100644 --- a/app/student/search/[job_id]/page.tsx +++ b/app/student/search/[job_id]/page.tsx @@ -1,184 +1,39 @@ -"use client"; - -import React, { useCallback, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; -import { ArrowLeft, X } from "lucide-react"; -import { Button, PageContainer, Card } from "@betterinternship/components"; -import { useProfileData, useJobData } from "@/lib/api/student.data.api"; -import { useModalRef } from "@/hooks/use-modal"; -import { useMobile } from "@/hooks/use-mobile"; -import { Loader } from "@/components/ui/loader"; -import { JobDetails } from "@/components/shared/jobs"; -import { ApplySuccessModal } from "@/components/modals/ApplySuccessModal"; -import { PageError } from "@/components/ui/error"; -import { SaveJobButton } from "@/components/features/student/job/save-job-button"; -import { ApplyToJobButton } from "@/components/features/student/job/apply-to-job-button"; -import { useApplicationActions } from "@/lib/api/student.actions.api"; -import { ShareJobButton } from "@/components/features/student/job/share-job-button"; -import type { ApplyPayload } from "@/components/modals/components/ApplyModal"; -import { toast } from "sonner"; +import { notFound } from "next/navigation"; +import { fetchJobFull } from "@/lib/api/job-preview.server"; +import { JobPageView } from "./JobPageView"; +import { getRefsData } from "@/lib/db/use-refs-backend"; +import { buildJobListingSchema } from "@/lib/seo/job-posting"; /** - * The individual job page. - * Allows viewing unlisted jobs. + * The individual job page. Server-rendered so the job's real content lands + * in the initial HTML for crawlers. */ -export default function JobPage() { - const router = useRouter(); - const params = useParams(); - const { job_id } = params; - const job = useJobData(job_id as string); - const [isActionsSheetOpen, setIsActionsSheetOpen] = useState(false); - const { isMobile } = useMobile(); - const applySuccessModalRef = useModalRef(); - const applicationActions = useApplicationActions(); - - const profile = useProfileData(); - - const handleApply = useCallback( - async ({ resumeId }: ApplyPayload) => { - if (!job.data?.id || !resumeId) return; +export const revalidate = 300; - const response = await applicationActions.create.mutateAsync({ - job_id: job.data.id, - resume_id: resumeId, - }); +export default async function JobPage({ + params, +}: { + params: Promise<{ job_id: string }>; +}) { + const { job_id } = await params; + const job = await fetchJobFull(job_id); - if (response.message) { - toast.error(response.message); - return; - } - - applySuccessModalRef.current?.open(); - }, - [applicationActions.create, job.data], - ); + if (job === null) notFound(); - if (job.error) - return ( - - ); - - if (!job || (!job.data && !job.isPending)) { - return ( -
-
- - -
-

Job does not exist.

-

- If you are coming from a valid link, it is possible that the job - was deleted by the employer. -

-
-
-
-
- ); - } + const refs = await getRefsData(); + const schema = buildJobListingSchema(job, refs); return ( <> - {/* Desktop and Mobile Layout */} -
- {job.isPending ? ( - Loading job details... - ) : ( -
-
-
-
- -
- {job.data && } - {job.data && !job.data.hibernating && ( - <> - - - - )} -
-
-
-
- - {job.data?.id && ( - - {/* Job Header Card */} - - {/* Job Details Grid */} - - - - )} - - {isMobile && isActionsSheetOpen && ( -
- -
- {job.data?.id && ( - setIsActionsSheetOpen(false)} - /> - )} -
-
- )} -
- )} -
- - + {schema && ( +