diff --git a/.prettierignore b/.prettierignore index 74d8c7232..16ffcff35 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,6 @@ node_modules dist build .next -*.min.js \ No newline at end of file +*.min.js +# copied verbatim from Career-Server/spec/spec.json +lib/api/generated/spec.json diff --git a/app/globals.css b/app/globals.css index f410dc0a4..ee877815a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -9,9 +9,34 @@ @source "../node_modules/@betterinternship/components/dist/**/*.js"; @theme { + --color-landing-navy: #101044; + --color-landing-blue: #0064ed; + --color-landing-muted: #5d719b; + --color-landing-border: #e1e7ef; + --color-landing-tint: #f0f6ff; + --color-landing-control: #d9e2ef; + --color-landing-control-hover: #c4cfdf; + --color-landing-border-hover: #cfd5df; + --color-landing-blue-hover: #0053c7; + --color-landing-nav: #374151; + --color-landing-nav-hover: #111827; + --color-landing-community: #0060e5; + --color-landing-footer: #fbfdff; + --color-landing-social: #66758c; + --color-landing-tag: #f1f4f8; + --color-landing-secondary: #c39035; + --color-landing-secondary-deep: #80571d; + --color-landing-secondary-tint: #fcf6e8; + --color-landing-secondary-border: #dfc58d; + --shadow-landing-card: 0 8px 24px rgb(15 23 42 / 5%); + --shadow-landing-interactive: 0 10px 28px rgb(31 45 68 / 8%); + --ease-landing-out: cubic-bezier(0.23, 1, 0.32, 1); + --animate-marquee-horizontal: marquee-x var(--duration) infinite linear; --animate-marquee-vertical: marquee-y var(--duration) linear infinite; --animate-fade-in: fade-in 0.5s ease-out; + --animate-landing-introduce: landing-introduce 360ms var(--ease-landing-out) + both; --animate-slide-up: slide-up 0.4s ease-out; --animate-scale-in: scale-in 0.3s ease-out; --animate-jitter: jitter 1.5s ease-in-out infinite; @@ -91,6 +116,16 @@ transform: translateY(0); } } + @keyframes landing-introduce { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } + } @keyframes slide-up { 0% { opacity: 0; diff --git a/app/hire/auth/secure-access/[employerUserId]/[hash]/route.ts b/app/hire/auth/secure-access/[employerUserId]/[hash]/route.ts index 87e27d060..08269cc7e 100644 --- a/app/hire/auth/secure-access/[employerUserId]/[hash]/route.ts +++ b/app/hire/auth/secure-access/[employerUserId]/[hash]/route.ts @@ -2,6 +2,9 @@ * Forwards minted employer magic links to the API endpoint that consumes the * keyring and sets the employer auth cookies. */ +import { API_ORIGIN } from "@/lib/api/api-origin"; +import { secureAccessUrl } from "@/lib/api/urls"; + export async function GET( request: Request, { @@ -9,15 +12,11 @@ export async function GET( }: { params: Promise<{ employerUserId: string; hash: string }> }, ) { const { employerUserId, hash } = await params; - const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, ''); - - if (!apiBaseUrl) { + if (!API_ORIGIN) { return Response.redirect(new URL('/login', request.url), 307); } - const apiUrl = new URL( - `${apiBaseUrl}/auth/secure-access/${encodeURIComponent(employerUserId)}/${encodeURIComponent(hash)}`, - ); + const apiUrl = new URL(secureAccessUrl(employerUserId, hash)); apiUrl.search = new URL(request.url).search; return Response.redirect(apiUrl, 307); diff --git a/app/hire/l/[slug]/route.ts b/app/hire/l/[slug]/route.ts index 06bc3d85c..90420fe83 100644 --- a/app/hire/l/[slug]/route.ts +++ b/app/hire/l/[slug]/route.ts @@ -2,6 +2,8 @@ * Resolves employer-portal short links using the shared links API. * Unknown slugs and API failures return the visitor to the employer home page. */ +import { LinkService } from "@/lib/api/services"; + export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> }, @@ -10,11 +12,7 @@ export async function GET( 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 }; + const data = await LinkService.resolve(slug, { cache: "no-store" }); return Response.redirect(data?.url ?? origin, 307); } catch { return Response.redirect(origin, 307); diff --git a/app/hire/register/page.tsx b/app/hire/register/page.tsx index b5a243072..1507fcb3d 100644 --- a/app/hire/register/page.tsx +++ b/app/hire/register/page.tsx @@ -1,12 +1,22 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useDbRefs } from "@/lib/db/use-refs"; import { useAuthContext } from "../authctx"; import { useRouter } from "next/navigation"; import { isValidRequiredURL, toURL } from "@/lib/utils/url-utils"; +import { + LOGO_REQUIREMENTS, + readLogoFile, + storePendingLogo, +} from "@/lib/utils/registration-logo"; import { Employer } from "@/lib/db/db.types"; -import { createEditForm, FormCheckbox, FormInput } from "@/components/EditForm"; +import { + createEditForm, + FormCheckbox, + FormInput, + LabelWithTooltip, +} from "@/components/EditForm"; import { ErrorLabel } from "@/components/ui/labels"; import { Button } from "@betterinternship/components"; import { isValidPHNumber } from "@/lib/utils"; @@ -74,9 +84,34 @@ const EmployerEditor = () => { {} as AdditionalFields, ); const [missingFields, setMissingFields] = useState([]); + const [logoDataUrl, setLogoDataUrl] = useState(null); + const [logoError, setLogoError] = useState(null); + const logoInputRef = useRef(null); const blurTransition = useBlurTransition(); + const handleLogoChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + // Reset so picking the same file again after removing it still fires. + event.target.value = ""; + if (!file) return; + + const result = await readLogoFile(file); + if ("error" in result) { + setLogoError(result.error); + return; + } + setLogoError(null); + setLogoDataUrl(result.dataUrl); + }; + + const removeLogo = () => { + setLogoDataUrl(null); + setLogoError(null); + }; + const continueRegistration = () => { // Validate required fields before submitting const newMissing: string[] = []; @@ -123,6 +158,9 @@ const EmployerEditor = () => { contact_name: additionalFields.contact_name, }; setIsContinuing(true); + // The account doesn't exist yet, so the verify step uploads this once the + // email is confirmed. Clears any logo left over from an earlier attempt. + storePendingLogo(logoDataUrl); sessionStorage.removeItem("hire-registration-email"); sessionStorage.setItem( "hire-registration-profile", @@ -273,6 +311,52 @@ const EmployerEditor = () => { : "", )} /> +
+ +
+ Company logo preview +
+
+ + {logoDataUrl && ( + + )} +
+ + {LOGO_REQUIREMENTS} + +
+ void handleLogoChange(event)} + accept="image/jpeg,image/png,image/webp" + className="hidden" + /> +
+ +
{ + const logo = takePendingLogo(); + if (!logo) return; + + try { + const form = FileUploadFormBuilder.new("logo").file(logo).build(); + const result = await EmployerService.updateMyPfp(form); + if (result.success) window.dispatchEvent(new Event(PFP_UPDATED_EVENT)); + } catch { + // Intentionally ignored, see above. + } + }; + const completeActivation = async () => { sessionStorage.removeItem("hire-registration-email"); sessionStorage.removeItem(OTP_SENT_AT_KEY); + await uploadPendingLogo(); await refreshAuthentication(); router.replace("/dashboard"); }; @@ -123,6 +147,8 @@ export default function VerifyHireRegistrationPage() { const response = await register(formData); if (!response.success) { if (response.account_exists) { + // Don't let this logo get applied to whichever account logs in next. + storePendingLogo(null); router.push(`/login?email=${encodeURIComponent(normalizedEmail)}`); return; } diff --git a/app/student/applications/page.tsx b/app/student/applications/page.tsx index c007c3299..f9509f594 100644 --- a/app/student/applications/page.tsx +++ b/app/student/applications/page.tsx @@ -54,6 +54,7 @@ import { SavedJobCard, SavedJobItem, } from "@/components/features/student/job/saved-job-card"; +import { fileUrls } from "@/lib/api/urls"; type TabValue = "applications" | "saved" | "alerts"; @@ -97,7 +98,8 @@ function MyJobsPageInner() { const { url: resumeURL, sync: syncResumeURL } = useFile({ fetcher: (resumeId: string) => UserService.getMyResumeURL(resumeId), - route: (resumeId: string) => `/users/me/resume/${resumeId}`, + route: (hash: string, resumeId: string) => + fileUrls.myResume(hash, resumeId), }); const { open: openResumeModal, Modal: ResumeModal } = useModal( "application-resume-modal", diff --git a/app/student/forms/components/FormSigningLayout.tsx b/app/student/forms/components/FormSigningLayout.tsx index 82f59619d..3e8dbee23 100644 --- a/app/student/forms/components/FormSigningLayout.tsx +++ b/app/student/forms/components/FormSigningLayout.tsx @@ -463,8 +463,9 @@ export function FormSigningLayout({ () => getRecipientEmailErrors(recipientEmails, { studentEmail: profile.data?.email, + recipients: noEsign ? undefined : recipients, }), - [recipientEmails, profile.data?.email], + [recipientEmails, profile.data?.email, recipients, noEsign], ); const nextEnabled = useMemo(() => { @@ -595,6 +596,16 @@ export function FormSigningLayout({ const handleSubmit = useCallback(async () => { setNextLoading(true); + if (Object.keys(recipientEmailErrors).length) { + recipientErrorActions.overwrite(recipientEmailErrors); + toast.error( + "Some recipient information is missing or incorrect", + toastPresets.destructive, + ); + if (!noRecipientStep) goToStep("timeline"); + setNextLoading(false); + return; + } const finalValuesFromFiller = formFiller.getFinalValues({ ...autofillValues, ...recipientEmails, @@ -605,7 +616,10 @@ export function FormSigningLayout({ ...latestValuesRef.current, } : finalValuesFromFiller; - const submittedValues = await withSubmittedSignatureImages(finalValues); + const submittedValues = await withSubmittedSignatureImages({ + ...finalValues, + ...recipientEmails, + }); const firstRecipient = getFirstRecipient(); const shouldGenerateForm = !!noEsign || !firstRecipient; @@ -685,6 +699,11 @@ export function FormSigningLayout({ onBack, recipientEmails, trackFilloutJob, + recipientEmailErrors, + recipientErrorActions, + noRecipientStep, + goToStep, + formFiller, ]); // Clean up when switching form diff --git a/app/student/forms/components/FormSigningPartyTimeline.tsx b/app/student/forms/components/FormSigningPartyTimeline.tsx index bf0f92a89..515cb042e 100644 --- a/app/student/forms/components/FormSigningPartyTimeline.tsx +++ b/app/student/forms/components/FormSigningPartyTimeline.tsx @@ -1,6 +1,7 @@ import { useFormRendererContext } from "@/components/features/student/forms/form-renderer.ctx"; import { StateRecord, StateRecordActions } from "@/hooks/base/useStateRecord"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import { getRecipientEmailOptions } from "@betterinternship/core/forms"; import { useProfileData } from "@/lib/api/student.data.api"; import { getRecipientEmailErrors, @@ -22,7 +23,10 @@ export const FormSigningPartyTimeline = ({ }) => { const form = useFormRendererContext(); const profile = useProfileData(); - const recipients = form.formMetadata.getSigningParties(); + const recipients = useMemo( + () => form.formMetadata.getSigningParties(), + [form.formMetadata], + ); useEffect(() => { if (!recipientInputAPI?.recipientErrorActions || isConfirmingRecipients) { @@ -30,11 +34,19 @@ export const FormSigningPartyTimeline = ({ } const validationTimeout = window.setTimeout(() => { - recipientInputAPI.recipientErrorActions.overwrite( - getRecipientEmailErrors(recipientInputAPI.recipientEmails, { + const errors = getRecipientEmailErrors( + recipientInputAPI.recipientEmails, + { studentEmail: profile.data?.email, - }), + recipients, + }, ); + if ( + JSON.stringify(errors) !== + JSON.stringify(recipientInputAPI.recipientErrors) + ) { + recipientInputAPI.recipientErrorActions.overwrite(errors); + } }, RECIPIENT_EMAIL_VALIDATION_DEBOUNCE_MS); return () => window.clearTimeout(validationTimeout); @@ -42,7 +54,9 @@ export const FormSigningPartyTimeline = ({ isConfirmingRecipients, profile.data?.email, recipientInputAPI?.recipientEmails, + recipientInputAPI?.recipientErrors, recipientInputAPI?.recipientErrorActions, + recipients, ]); return ( @@ -51,6 +65,7 @@ export const FormSigningPartyTimeline = ({ id: form.formMetadata.getSigningPartyFieldName(recipient._id), title: recipient.signatory_title, email: recipient.signatory_account?.email ?? "", + emailOptions: getRecipientEmailOptions(recipient), isMe: recipient._id === "initiator", isEditable: recipient.signatory_source?._id === "initiator", }))} diff --git a/app/student/forms/components/RecipientSigningPartyTimeline.tsx b/app/student/forms/components/RecipientSigningPartyTimeline.tsx index 2a4bc8bce..9a6e69bdd 100644 --- a/app/student/forms/components/RecipientSigningPartyTimeline.tsx +++ b/app/student/forms/components/RecipientSigningPartyTimeline.tsx @@ -10,6 +10,7 @@ export type RecipientTimelineParty = { email?: string; isMe?: boolean; isEditable?: boolean; + emailOptions?: string[]; }; export function RecipientSigningPartyTimeline({ @@ -70,19 +71,48 @@ export function RecipientSigningPartyTimeline({ Current email: {party.email || "No email set"} - onEditableEmailChange?.(e.target.value)} - placeholder="name@company.com" - disabled={editableDisabled} - /> + {party.emailOptions !== undefined ? ( + + ) : ( + + onEditableEmailChange?.(e.target.value) + } + placeholder="name@company.com" + disabled={editableDisabled} + /> + )} {editableError && ( {editableError} @@ -91,7 +121,9 @@ export function RecipientSigningPartyTimeline({
) : !recipientInputAPI?.recipientEmails ? ( - you will specify this email + {party.emailOptions !== undefined + ? "you will select this email" + : "you will specify this email"} ) : isConfirmingRecipients ? ( @@ -99,26 +131,58 @@ export function RecipientSigningPartyTimeline({ ) : (
- - recipientInputAPI?.recipientErrorActions?.clearOne( - fieldName, - ) - } - value={recipientValue} - placeholder={"Enter email..."} - className={cn( - recipientError - ? "border-destructive text-destructive" - : "", - )} - setter={(value) => - recipientInputAPI?.recipientEmailActions.setOne( - fieldName, - value, - ) - } - /> + {party.emailOptions !== undefined ? ( + + ) : ( + + recipientInputAPI?.recipientErrorActions?.clearOne( + fieldName, + ) + } + value={recipientValue} + placeholder={"Enter email..."} + className={cn( + recipientError + ? "border-destructive text-destructive" + : "", + )} + setter={(value) => + recipientInputAPI?.recipientEmailActions.setOne( + fieldName, + value, + ) + } + /> + )} {recipientError && ( {recipientError} diff --git a/app/student/forms/components/recipient-email-validation.ts b/app/student/forms/components/recipient-email-validation.ts index f3ce1f4c2..470944cb4 100644 --- a/app/student/forms/components/recipient-email-validation.ts +++ b/app/student/forms/components/recipient-email-validation.ts @@ -1,4 +1,8 @@ import { isValidEmail } from "@/lib/utils/string-utils"; +import { + getRecipientEmailValidator, + type IFormSigningParty, +} from "@betterinternship/core/forms"; export const RECIPIENT_EMAIL_VALIDATION_DEBOUNCE_MS = 300; @@ -6,6 +10,7 @@ const normalizeEmail = (email: string) => email.trim().toLowerCase(); type RecipientEmailValidationOptions = { studentEmail?: string | null; + recipients?: IFormSigningParty[]; }; export const getRecipientEmailErrors = ( @@ -16,6 +21,22 @@ export const getRecipientEmailErrors = ( ? normalizeEmail(options.studentEmail) : undefined; const fieldNamesByEmail = new Map(); + const choiceErrors: Record = {}; + for (const recipient of options.recipients ?? []) { + if ( + recipient.signatory_source?._id !== "initiator" || + recipient.signatory_email_options === undefined + ) + continue; + const fieldName = `__${recipient._id}-email:default`; + if ( + !getRecipientEmailValidator(recipient).safeParse( + recipientEmails[fieldName], + ).success + ) { + choiceErrors[fieldName] = "Select a configured recipient email."; + } + } return Object.entries(recipientEmails).reduce>( (errors, [fieldName, emailValue]) => { @@ -57,6 +78,6 @@ export const getRecipientEmailErrors = ( return errors; }, - {}, + choiceErrors, ); }; diff --git a/app/student/forms/edit-recipient/page.tsx b/app/student/forms/edit-recipient/page.tsx index 07e7b0411..cdc9f0402 100644 --- a/app/student/forms/edit-recipient/page.tsx +++ b/app/student/forms/edit-recipient/page.tsx @@ -18,6 +18,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { RecipientSigningPartyTimeline } from "../components/RecipientSigningPartyTimeline"; import { getRecipientEmailErrors } from "../components/recipient-email-validation"; import useModalRegistry from "@/components/modals/modal-registry"; +import { getRecipientEmailValidator } from "@betterinternship/core/forms"; export default function EditRecipientPage() { const { redirectIfNotLoggedIn } = useAuthContext(); @@ -50,6 +51,9 @@ export default function EditRecipientPage() { const signingPartyTitle = context?.signingPartyTitle || "Recipient"; const targetSigningPartyId = context?.targetSigningPartyId || ""; const signingParties = context?.signingParties || []; + const emailOptions = context?.signingParties.find( + (party) => party.id === targetSigningPartyId, + )?.emailOptions; const [recipientEmail, setRecipientEmail] = useState(""); const [submitting, setSubmitting] = useState(false); const [statusMessage, setStatusMessage] = useState(""); @@ -64,6 +68,15 @@ export default function EditRecipientPage() { ["student", "initiator"].includes(title.trim().toLowerCase()); const editableError = useMemo(() => { if (!targetSigningPartyId) return ""; + if ( + emailOptions !== undefined && + normalizedRecipientEmail && + !getRecipientEmailValidator({ + signatory_email_options: emailOptions, + }).safeParse(recipientEmail).success + ) { + return "Select a configured recipient email."; + } if ( normalizedRecipientEmail && @@ -84,6 +97,7 @@ export default function EditRecipientPage() { profile.data?.email, recipientEmail, targetSigningPartyId, + emailOptions, ]); redirectIfNotLoggedIn(); @@ -211,6 +225,7 @@ export default function EditRecipientPage() { id: party.id, title: party.title, email: party.email, + emailOptions: party.emailOptions, isMe: isCurrentUserSigningParty(party.title), isEditable: party.id === targetSigningPartyId, }))} @@ -223,7 +238,12 @@ export default function EditRecipientPage() {
)} -
+ { + void onSubmit(event); + }} + > {isConfirming && (
diff --git a/app/student/l/[slug]/route.ts b/app/student/l/[slug]/route.ts index 2200267c9..0a715c0bf 100644 --- a/app/student/l/[slug]/route.ts +++ b/app/student/l/[slug]/route.ts @@ -7,6 +7,8 @@ * erroring, and a temporary redirect means a mis-pointed slug can still be * fixed later instead of staying cached in every browser that clicked it (D10). */ +import { LinkService } from "@/lib/api/services"; + export async function GET( request: Request, { params }: { params: Promise<{ slug: string }> }, @@ -15,11 +17,7 @@ export async function GET( 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 }; + const data = await LinkService.resolve(slug, { cache: "no-store" }); return Response.redirect(data?.url ?? origin, 307); } catch { return Response.redirect(origin, 307); diff --git a/app/student/page.tsx b/app/student/page.tsx index a50093708..46da82944 100644 --- a/app/student/page.tsx +++ b/app/student/page.tsx @@ -1,43 +1,75 @@ -import { HeroSection, Feature } from "@/components/landingStudent/sections"; -import Testimonials from "@/components/landingStudent/sections/3rdSection/testimonials"; -import { Footer } from "@/components/shared/footer"; -import { UserService } from "@/lib/api/services"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; +import type { Metadata } from "next"; +import { StudentHomepage } from "@/components/landingStudent/homepage"; +import { fetchHomepageListings } from "@/components/landingStudent/homepage-listings.server"; +import { getRefsData } from "@/lib/db/use-refs-backend"; +import { baseUrl } from "@/lib/site-url"; -async function isLoggedIn() { - const cookieHeader = (await cookies()).toString(); - if (!cookieHeader) return false; +// Cache the rendered homepage like the Top pages. Category-tag invalidation +// refreshes it sooner; a week is only the fallback lifetime. +export const revalidate = 604800; // TOP_PAGES_REVALIDATE_SECONDS - try { - const response = await UserService.getMyProfile({ - cache: "no-store", - headers: { - cookie: cookieHeader, - }, - }); +const title = "BetterInternship | Find internships in the Philippines"; +const description = + "Discover internships across the Philippines. Explore opportunities by field, find remote and paid internships, and apply in one click with BetterInternship."; - return !!response.user; - } catch { - return false; - } -} +export const metadata: Metadata = { + title, + description, + alternates: { canonical: `${baseUrl}/` }, + openGraph: { + title, + description, + url: `${baseUrl}/`, + siteName: "BetterInternship", + type: "website", + images: [ + { + url: `${baseUrl}/homepage/og.png`, + width: 1200, + height: 630, + alt: "BetterInternship — discover internships across the Philippines", + }, + ], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [`${baseUrl}/homepage/og.png`], + }, +}; export default async function HomePage() { - if (await isLoggedIn()) { - redirect("/search"); - } - + const refs = await getRefsData(); + const listings = await fetchHomepageListings(refs); + const schema = { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": `${baseUrl}/#organization`, + name: "BetterInternship", + url: `${baseUrl}/`, + logo: `${baseUrl}/BetterInternshipLogo.png`, + }, + { + "@type": "WebSite", + "@id": `${baseUrl}/#website`, + name: "BetterInternship", + url: `${baseUrl}/`, + publisher: { "@id": `${baseUrl}/#organization` }, + }, + ], + }; return ( -
- {/* Hero Section */} - - {/* Apply Fast */} - - {/* Benefits clickable */} - - -
-
+ <> +