diff --git a/landing-page/README.md b/landing-page/README.md index 6e958bd..cc3ae82 100644 --- a/landing-page/README.md +++ b/landing-page/README.md @@ -13,13 +13,31 @@ root so the waitlist can be deployed on its own. The public form collects: - First name (required) -- Email address or phone number (required) -- University (optional) -- Discipline or area of study (optional) -- Consent (required) - -Responses are organised in the form owner's linked private Google Sheet. No -waitlist submissions or credentials are stored in this repository. +- Email address (required) +- WhatsApp number (optional) +- University or campus (required) +- Course of study (required) +- Explicit early-access consent (required) + +The browser submits JSON to `POST /api/waitlist`. The server validates the +request, applies basic bot and rate-limit controls, and only reports success +after the existing Google Form accepts it. Responses remain organised in the +form owner's linked private Google Sheet. No submissions or credentials are +stored in this repository. + +Waitlist data is intended only for pilot planning and contact. Public copy sets +a maximum 12-month retention period, with earlier removal when the pilot closes +or the person withdraws through `privacy@fixars.ai`. The mailbox must be verified +before a production release. + +## Course taster + +`src/courseClassifier.js` contains the pure course-title classifier. It removes +common degree awards, handles aliases, small spelling differences and joint +courses, and ranks 12 broad course families. Strong matches generate a grouped +skill preview; ambiguous and unknown titles require the person to choose a +family instead of presenting a generic result as fact. Every preview is labelled +as a course-title-only suggestion that still needs evidence. ## Local development @@ -35,6 +53,9 @@ npm run build npm run test:sites ``` +The test suite covers course matching, request validation, upstream failure, +same-origin enforcement, body limits, throttling, static assets and SPA routing. + ## Deployment scope The `.openai/hosting.json` file links this subproject to the existing Fixars diff --git a/landing-page/package.json b/landing-page/package.json index f7381c1..053e737 100644 --- a/landing-page/package.json +++ b/landing-page/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "vite build && node scripts/prepare-sites-build.mjs", "preview": "vite preview", - "test:sites": "node --test tests/sites-worker.test.mjs" + "test:sites": "node --test tests/course-classifier.test.mjs tests/sites-worker.test.mjs" }, "dependencies": { "@vitejs/plugin-react": "5.0.4", diff --git a/landing-page/src/App.jsx b/landing-page/src/App.jsx index 4983ffd..5e437b1 100644 --- a/landing-page/src/App.jsx +++ b/landing-page/src/App.jsx @@ -1,232 +1,353 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useRef, useState } from "react"; +import { + COURSE_FAMILIES, + POPULAR_COURSES, + classifyCourse, + profileForFamily, +} from "./courseClassifier.js"; const FORM_URL = "https://docs.google.com/forms/d/e/1FAIpQLSfphC9GZD2uJR6Ezv7IgX8_R7g6_JC7wOA7qeGBBVAzxBU_Dg/viewform?usp=publish-editor"; -const FORM_RESPONSE_URL = - "https://docs.google.com/forms/d/e/1FAIpQLSfphC9GZD2uJR6Ezv7IgX8_R7g6_JC7wOA7qeGBBVAzxBU_Dg/formResponse"; - -const formEntries = { - firstName: "entry.1211410083", - contact: "entry.1093468283", - university: "entry.1721877843", - discipline: "entry.1556195189", - consent: "entry.755150101", -}; const emptyWaitlistForm = { firstName: "", - contact: "", + email: "", + whatsapp: "", university: "", - discipline: "", + course: "", + microskills: "", consent: false, + website: "", }; -const disciplines = { - Economics: ["Financial modelling", "Data analysis", "Policy writing", "Market research"], - "Computer Science": ["Frontend development", "API integration", "Testing", "Data structures"], - "Mass Communication": ["Copywriting", "Interviewing", "Video editing", "Campaign planning"], - "Mechanical Engineering": ["CAD modelling", "Technical drawing", "Materials testing", "Maintenance planning"], - Law: ["Legal research", "Case briefing", "Contract review", "Policy analysis"], - Microbiology: ["Lab safety", "Sample preparation", "Microscopy", "Culture techniques"], -}; - -function skillsFor(value) { - const exact = disciplines[value]; - if (exact) return exact; - - const term = value.toLowerCase(); - if (/(comput|software|data|cyber|tech)/.test(term)) { - return ["Problem decomposition", "Data analysis", "Technical documentation", "Testing"]; - } - if (/(business|econom|finance|account|market)/.test(term)) { - return ["Spreadsheet modelling", "Market research", "Presentation", "Commercial analysis"]; - } - if (/(engineer|mechan|civil|electr|build)/.test(term)) { - return ["Technical drawing", "Requirements analysis", "Quality checks", "Project delivery"]; - } - if (/(media|communicat|journal|design|creative)/.test(term)) { - return ["Story development", "Audience research", "Content production", "Campaign planning"]; - } - if (/(law|legal|politic|policy)/.test(term)) { - return ["Evidence review", "Structured writing", "Case research", "Stakeholder analysis"]; - } - if (/(bio|chem|science|medical|health)/.test(term)) { - return ["Lab practice", "Data recording", "Research review", "Technical reporting"]; +function validateForm(details) { + const errors = {}; + if (!details.firstName.trim()) errors.firstName = "Enter your first name."; + if (!/^\S+@\S+\.\S+$/.test(details.email.trim())) errors.email = "Enter a valid email address."; + if (details.whatsapp && details.whatsapp.replace(/\D/g, "").length < 7) { + errors.whatsapp = "Enter a complete WhatsApp number or leave it blank."; } - return ["Research", "Structured problem-solving", "Technical writing", "Project delivery"]; + if (details.university.trim().length < 2) errors.university = "Enter your university or campus."; + if (details.course.trim().length < 2) errors.course = "Enter your course of study."; + if (!details.consent) errors.consent = "Please agree to the early-access data notice."; + return errors; } -function WaitlistForm({ className = "", intro, submitLabel = "Join early access" }) { - const [details, setDetails] = useState(emptyWaitlistForm); +function WaitlistForm({ details, onChange, onCourseEdit, firstInputRef }) { + const [errors, setErrors] = useState({}); const [status, setStatus] = useState("idle"); function updateDetail(field, value) { - setDetails((current) => ({ ...current, [field]: value })); + onChange((current) => ({ ...current, [field]: value })); + setErrors((current) => ({ ...current, [field]: undefined })); + if (field === "course") onCourseEdit(); } async function submitWaitlist(event) { event.preventDefault(); - setStatus("submitting"); - - const formData = new FormData(); - formData.append(formEntries.firstName, details.firstName.trim()); - formData.append(formEntries.contact, details.contact.trim()); - formData.append(formEntries.university, details.university.trim()); - formData.append(formEntries.discipline, details.discipline.trim()); - formData.append(formEntries.consent, "I agree"); + const nextErrors = validateForm(details); + if (Object.keys(nextErrors).length) { + setErrors(nextErrors); + setStatus("invalid"); + return; + } + setStatus("submitting"); try { - await fetch(FORM_RESPONSE_URL, { + const response = await fetch("/api/waitlist", { method: "POST", - mode: "no-cors", - body: formData, + headers: { "content-type": "application/json" }, + body: JSON.stringify(details), }); + const result = await response.json(); + if (!response.ok) { + setErrors(result.errors || {}); + setStatus("error"); + return; + } setStatus("success"); - setDetails(emptyWaitlistForm); } catch { setStatus("error"); } } + if (status === "success") { + return ( +
+ Early access requested +

You are on the list, {details.firstName.trim()}.

+

We will use your email to share the next pilot step when it is relevant to your campus.

+
+ ); + } + + const errorCount = Object.values(errors).filter(Boolean).length; return ( -
- {intro &&

{intro}

} + + {errorCount > 0 && ( +
+ Please check {errorCount === 1 ? "the highlighted field" : `${errorCount} highlighted fields`}. +
+ )} + + + + + {errors.consent && {errors.consent}} +

+ We keep waitlist data for no more than 12 months, or delete it earlier when the pilot closes or you ask us to. + Contact privacy@fixars.ai to withdraw. +

- {status === "success" && ( -

- You are on the waitlist. We will be in touch when your campus opens. -

- )} {status === "error" && (

- Something did not go through. Please try again or use the{" "} - - secure back-up form - - . + We could not confirm your place. Try again, or use the{" "} + secure back-up form.

)}
); } -function WaitlistModal({ onClose }) { - const closeButtonRef = useRef(null); - - useEffect(() => { - const previousOverflow = document.body.style.overflow; - - document.body.style.overflow = "hidden"; - closeButtonRef.current?.focus(); - - function handleKeyDown(event) { - if (event.key === "Escape") onClose(); - } +function SkillProfile({ result, selectedSkills, onSelect, onToggleSkill, onPrefill }) { + if (!result) { + return ( +
+ +

Your potential skill map starts here.

+

Enter a course title or choose an example to see a grounded preview.

+
+ ); + } - document.addEventListener("keydown", handleKeyDown); + if (result.status === "ambiguous") { + return ( +
+ A QUICK CHECK +

Which area is closest?

+

We found more than one sensible match for “{result.course}”.

+
+ {result.suggestions.map((suggestion) => ( + + ))} +
+
+ ); + } - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.body.style.overflow = previousOverflow; - }; - }, [onClose]); + if (result.status === "unknown") { + return ( +
+ HELP US PLACE IT +

Choose the nearest course family

+

Your course remains exactly as you entered it; this choice only improves the skill preview.

+
+ {COURSE_FAMILIES.map((family) => ( + + ))} +
+
+ ); + } + const groups = [ + ["Course-derived", result.skills.courseDerived], + ["Applied", result.skills.applied], + ["Transferable", result.skills.transferable], + ]; return ( -
-
event.stopPropagation()} - > -
-
- FIXARS EARLY ACCESS -

Join the waitlist

-
- +
+
+
+ PROFILE PREVIEW +

{result.course}

+

{result.families.map((family) => family.label).join(" + ")}

- -

- Back-up sign-up link:{" "} - - open the secure Google form - - . -

-
+ To verify +
+

Potential skill areas to verify

+
+ {groups.map(([label, skills]) => ( +
+

{label}

+
+ {skills.map((skill) => ( + + ))} +
+
+ ))} +
+

{result.basis}

+
+ + +
); } function App() { - const [waitlistOpen, setWaitlistOpen] = useState(false); const [qualification, setQualification] = useState(""); - const [profile, setProfile] = useState(null); - - const profileSkills = useMemo(() => (profile ? skillsFor(profile) : []), [profile]); - - function openWaitlist() { - setWaitlistOpen(true); + const [result, setResult] = useState(null); + const [details, setDetails] = useState(emptyWaitlistForm); + const [courseEdited, setCourseEdited] = useState(false); + const [selectedSkills, setSelectedSkills] = useState([]); + const firstInputRef = useRef(null); + + function focusJoin() { + const joinSection = document.getElementById("join"); + joinSection?.scrollIntoView({ behavior: "smooth", block: "start" }); + window.setTimeout(() => { + const firstIncomplete = [...(joinSection?.querySelectorAll("input[required]") || [])].find((input) => + input.type === "checkbox" ? !input.checked : !input.value.trim(), + ); + (firstIncomplete || firstInputRef.current)?.focus(); + }, 350); } function buildProfile(event) { event.preventDefault(); - const clean = qualification.trim(); - if (!clean) return; - setProfile(clean); + const nextResult = classifyCourse(qualification); + if (nextResult.status === "empty") return; + setResult(nextResult); + setSelectedSkills(nextResult.status === "matched" ? Object.values(nextResult.skills).flat() : []); + if (!courseEdited) setDetails((current) => ({ ...current, course: qualification.trim() })); + } + + function selectFamily(familyId) { + const nextResult = profileForFamily(familyId, qualification); + setResult(nextResult); + setSelectedSkills(Object.values(nextResult.skills).flat()); + if (!courseEdited) setDetails((current) => ({ ...current, course: qualification.trim() })); + } + + function chooseCourse(course) { + setQualification(course); + const nextResult = classifyCourse(course); + setResult(nextResult); + setSelectedSkills(nextResult.status === "matched" ? Object.values(nextResult.skills).flat() : []); + if (!courseEdited) setDetails((current) => ({ ...current, course })); + } + + function prefillWithSkills() { + const skillText = selectedSkills.join(", "); + setDetails((current) => ({ + ...current, + course: courseEdited ? current.course : qualification.trim(), + microskills: skillText, + })); + focusJoin(); } return ( <> - - Skip to content - + Skip to content
@@ -234,72 +355,55 @@ function App() { - +
- - -

- Turn your degree into verified skills employers can check. -

+ +

Turn what you study into skills you can prove.

- A degree says what you studied. Fixars shows what you can actually do — verified micro-skills, - real projects, and a reputation that travels with you. Earn before you graduate. Walk into the - job market with receipts, not just results. + Fixars helps students translate a course of study into potential skills, gather evidence, and discover + connected pathways across SkillsCanvas, ConceptsNexus and CollaBoard.

-
- S -

List micro-skills you can prove — not buzzwords

-
-
- P -

Earn from real paid projects while you study

-
-
- R -

One reputation score across everything you do

-
+
01

Map your course into skill areas worth investigating

+
02

Add evidence that shows what you can actually do

+
03

Explore relevant people, ideas and project pathways

- Emerging - Your score starts honest and grows with every verified skill. + Evidence first + A suggestion is never treated as a verified skill until you support it.
-
- THE 20-SECOND TASTER -

See your profile before it exists

-

- This is the real first step of Fixars onboarding. Pick what you study, pick what you can - actually do — and watch a verifiable profile appear. -

+ THE COURSE TASTER +

See the skill areas your course could open up

+

Start with a course title. We will suggest areas to explore, then you decide what your evidence can support.

-
@@ -308,69 +412,60 @@ function App() { id="qualification" value={qualification} onChange={(event) => setQualification(event.target.value)} - placeholder="e.g. Economics" + placeholder="e.g. BSc Computer Science" autoComplete="off" /> - +
-
- {Object.keys(disciplines).map((discipline) => ( - ))}
- -
- {!profile ? ( -
- -

Your micro-skill map starts here.

-

Type your qualification or pick a discipline to see a grounded profile preview.

-
- ) : ( - <> -
-
- PROFILE PREVIEW -

{profile}

-
- Emerging -
-

Micro-skills to verify

-
- {profileSkills.map((skill, index) => ( -
- {String(index + 1).padStart(2, "0")} - {skill} -
- ))} -
-

A preview only — your real profile grows through evidence and verification.

- - )} +
+ setSelectedSkills((current) => current.includes(skill) ? current.filter((entry) => entry !== skill) : [...current, skill])} + onPrefill={prefillWithSkills} + />
+
+
+ WHAT EARLY ACCESS MEANS +

A focused student pilot, not a promise of a job.

+
+
+
+ Who is this first pilot for? +

We are starting with university students and recent graduates who want a clearer way to express and evidence their capabilities.

+
+
+ What happens after I join? +

We record your campus and course, then contact you when onboarding, research or testing is relevant. Joining does not guarantee access, placement or earnings.

+
+
+ How is my waitlist data handled? +

It is used for pilot planning and contact, kept for no more than 12 months, and removed earlier when the pilot closes or you withdraw through privacy@fixars.ai.

+
+
+
+
- YOUR NEXT MOVE -

Get in before your campus does.

-

Launching soon at a uni near you. No placement promises — just receipts.

+ HELP SHAPE THE PILOT +

Start with the course you already know.

+

Join the research and early-access list for your campus.

- +
@@ -379,11 +474,9 @@ function App() { Fixars -

One account. One wallet. One reputation.

- +

Skills, evidence and opportunities — connected.

+ - - {waitlistOpen && setWaitlistOpen(false)} />} ); } diff --git a/landing-page/src/courseClassifier.js b/landing-page/src/courseClassifier.js new file mode 100644 index 0000000..6400956 --- /dev/null +++ b/landing-page/src/courseClassifier.js @@ -0,0 +1,276 @@ +const DEGREE_WORDS = new Set([ + "ba", + "bachelor", + "bachelors", + "beng", + "bsc", + "diploma", + "hnd", + "honours", + "honors", + "hons", + "ma", + "masters", + "meng", + "msc", + "nd", + "of", + "science", +]); + +export const COURSE_FAMILIES = [ + { + id: "computing", + label: "Computing and technology", + aliases: ["computer science", "computing", "computer engineering", "software engineering", "software development", "information technology", "information systems", "informatics", "data science", "data analytics", "business analytics", "cyber security", "cybersecurity", "artificial intelligence", "machine learning", "cloud computing", "network engineering", "telecommunications", "web development", "mobile app development", "game development", "database management", "human computer interaction", "user experience design", "computer applications"], + skills: { + courseDerived: ["Problem decomposition", "Data reasoning"], + applied: ["Software testing", "Technical documentation"], + transferable: ["Systems thinking", "Collaborative delivery"], + }, + }, + { + id: "engineering", + label: "Engineering", + aliases: ["mechanical engineering", "civil engineering", "electrical engineering", "electronic engineering", "chemical engineering", "mechatronics", "aerospace engineering", "biomedical engineering", "petroleum engineering", "automotive engineering", "industrial engineering", "systems engineering", "production engineering", "structural engineering", "materials engineering", "robotics engineering", "environmental engineering", "agricultural engineering", "engineering"], + skills: { + courseDerived: ["Requirements analysis", "Technical modelling"], + applied: ["Quality checks", "Design documentation"], + transferable: ["Structured problem-solving", "Project delivery"], + }, + }, + { + id: "business-economics", + label: "Business and economics", + aliases: ["economics", "business administration", "business management", "business studies", "commerce", "accounting", "finance", "banking and finance", "marketing", "human resources", "supply chain management", "logistics", "operations management", "project management", "entrepreneurship", "actuarial science", "insurance", "real estate", "estate management", "tourism management", "hospitality management", "procurement", "management"], + skills: { + courseDerived: ["Commercial analysis", "Market research"], + applied: ["Spreadsheet modelling", "Business presentation"], + transferable: ["Decision framing", "Stakeholder communication"], + }, + }, + { + id: "communications-creative", + label: "Communications and creative arts", + aliases: ["mass communication", "communication arts", "journalism", "broadcasting", "media studies", "digital media", "public relations", "graphic design", "visual communication", "film studies", "film production", "creative arts", "advertising", "animation", "photography", "fashion design", "fine arts", "music", "music production", "theatre arts", "performing arts", "publishing"], + skills: { + courseDerived: ["Audience research", "Story development"], + applied: ["Content production", "Campaign planning"], + transferable: ["Clear communication", "Creative collaboration"], + }, + }, + { + id: "law-policy", + label: "Law and public policy", + aliases: ["law", "legal studies", "corporate law", "political science", "politics", "public policy", "public administration", "governance", "international relations", "international development", "criminology", "security studies", "peace studies", "human rights"], + skills: { + courseDerived: ["Evidence review", "Policy analysis"], + applied: ["Case research", "Structured argument"], + transferable: ["Critical reasoning", "Stakeholder analysis"], + }, + }, + { + id: "life-health", + label: "Life and health sciences", + aliases: ["microbiology", "biology", "biochemistry", "biotechnology", "genetics", "molecular biology", "medicine", "medical science", "nursing", "pharmacy", "public health", "epidemiology", "anatomy", "physiology", "physiotherapy", "radiography", "medical laboratory science", "medical rehabilitation", "nutrition", "dietetics", "dentistry", "veterinary medicine", "neuroscience", "health information management"], + skills: { + courseDerived: ["Scientific observation", "Research review"], + applied: ["Data recording", "Technical reporting"], + transferable: ["Evidence-led decisions", "Ethical practice"], + }, + }, + { + id: "physical-sciences", + label: "Physical sciences", + aliases: ["chemistry", "industrial chemistry", "analytical chemistry", "physics", "applied physics", "mathematics", "applied mathematics", "statistics", "data statistics", "geology", "geophysics", "meteorology", "astronomy", "materials science"], + skills: { + courseDerived: ["Quantitative analysis", "Scientific modelling"], + applied: ["Experimental design", "Data interpretation"], + transferable: ["Logical reasoning", "Precise communication"], + }, + }, + { + id: "social-sciences", + label: "Social sciences", + aliases: ["sociology", "psychology", "social work", "social policy", "anthropology", "development studies", "development economics", "geography", "human geography", "demography", "gender studies", "community development", "social sciences"], + skills: { + courseDerived: ["Human-centred research", "Behavioural analysis"], + applied: ["Interview design", "Qualitative synthesis"], + transferable: ["Empathy", "Contextual reasoning"], + }, + }, + { + id: "education", + label: "Education", + aliases: ["education", "education management", "educational management", "guidance and counselling", "early childhood education", "primary education", "secondary education", "special education", "adult education", "science education", "technical education", "curriculum studies", "educational psychology", "teaching"], + skills: { + courseDerived: ["Learning design", "Assessment planning"], + applied: ["Facilitation", "Progress evaluation"], + transferable: ["Coaching", "Clear communication"], + }, + }, + { + id: "built-environment", + label: "Built environment", + aliases: ["architecture", "quantity surveying", "estate management", "urban planning", "town planning", "building technology", "construction management", "surveying", "geomatics", "land surveying", "property management", "interior design", "landscape architecture"], + skills: { + courseDerived: ["Spatial reasoning", "Specification review"], + applied: ["Design documentation", "Project planning"], + transferable: ["Constraint management", "Team coordination"], + }, + }, + { + id: "agriculture-environment", + label: "Agriculture and environment", + aliases: ["agriculture", "agricultural economics", "agricultural science", "crop science", "animal science", "fisheries", "forestry", "soil science", "environmental science", "environmental management", "conservation", "climate science", "water resources", "food science", "fisheries management"], + skills: { + courseDerived: ["Environmental assessment", "Resource analysis"], + applied: ["Field data collection", "Sustainability planning"], + transferable: ["Systems thinking", "Community engagement"], + }, + }, + { + id: "humanities-languages", + label: "Humanities and languages", + aliases: ["english", "english language", "english literature", "history", "philosophy", "linguistics", "languages", "modern languages", "french", "german", "spanish", "religious studies", "theology", "classics", "cultural studies", "literature", "theatre arts"], + skills: { + courseDerived: ["Textual analysis", "Contextual research"], + applied: ["Editorial writing", "Argument development"], + transferable: ["Critical thinking", "Cultural awareness"], + }, + }, +]; + +export const POPULAR_COURSES = [ + "Computer Science", + "Economics", + "Mass Communication", + "Mechanical Engineering", + "Law", + "Microbiology", +]; + +export function normalizeCourse(value = "") { + return value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/\bb\s*\.\s*sc\b/g, " bsc ") + .replace(/\bb\s*\.\s*eng\b/g, " beng ") + .replace(/\bm\s*\.\s*sc\b/g, " msc ") + .replace(/&/g, " and ") + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter((word) => !DEGREE_WORDS.has(word)) + .join(" "); +} + +function bigrams(value) { + const compact = value.replace(/\s+/g, " "); + if (compact.length < 2) return new Set([compact]); + return new Set(Array.from({ length: compact.length - 1 }, (_, index) => compact.slice(index, index + 2))); +} + +function diceCoefficient(left, right) { + const leftSet = bigrams(left); + const rightSet = bigrams(right); + let matches = 0; + leftSet.forEach((entry) => { + if (rightSet.has(entry)) matches += 1; + }); + return (2 * matches) / Math.max(1, leftSet.size + rightSet.size); +} + +function tokenOverlap(left, right) { + const leftTokens = new Set(left.split(" ").filter(Boolean)); + const rightTokens = new Set(right.split(" ").filter(Boolean)); + const intersection = [...leftTokens].filter((token) => rightTokens.has(token)).length; + const union = new Set([...leftTokens, ...rightTokens]).size; + return intersection / Math.max(1, union); +} + +function scoreAlias(input, alias) { + const normalizedAlias = normalizeCourse(alias); + if (input === normalizedAlias) return 1; + if (input.includes(normalizedAlias) || normalizedAlias.includes(input)) return 0.9; + return 0.62 * diceCoefficient(input, normalizedAlias) + 0.38 * tokenOverlap(input, normalizedAlias); +} + +export function rankCourseMatches(value) { + const input = normalizeCourse(value); + if (!input) return []; + + return COURSE_FAMILIES.map((family) => { + const aliases = family.aliases.map((alias) => ({ alias, score: scoreAlias(input, alias) })); + aliases.sort((left, right) => right.score - left.score); + return { ...family, matchedAlias: aliases[0].alias, score: aliases[0].score }; + }).sort((left, right) => right.score - left.score); +} + +function mergeSkills(families) { + const groups = { courseDerived: [], applied: [], transferable: [] }; + Object.keys(groups).forEach((group) => { + families.forEach((family) => { + family.skills[group].forEach((skill) => { + if (!groups[group].includes(skill) && groups[group].length < 2) groups[group].push(skill); + }); + }); + }); + return groups; +} + +export function profileForFamily(familyId, courseLabel) { + const family = COURSE_FAMILIES.find((entry) => entry.id === familyId); + if (!family) return null; + return { + status: "matched", + course: courseLabel?.trim() || family.label, + families: [family], + skills: mergeSkills([family]), + basis: "Suggested from the course title only. Verify each area with evidence before adding it to a profile.", + }; +} + +export function classifyCourse(value) { + const course = value.trim(); + const normalized = normalizeCourse(course); + if (!normalized) return { status: "empty", course: "", suggestions: [], families: [] }; + + const jointParts = normalized.split(/\b(?:and|with)\b/).map((part) => part.trim()).filter(Boolean); + if (jointParts.length > 1) { + const jointFamilies = jointParts + .map((part) => rankCourseMatches(part)[0]) + .filter((match) => match?.score >= 0.58) + .filter((match, index, entries) => entries.findIndex((entry) => entry.id === match.id) === index) + .slice(0, 2); + if (jointFamilies.length > 1) { + return { + status: "matched", + course, + families: jointFamilies, + skills: mergeSkills(jointFamilies), + basis: "Suggested from the joint course title only. Verify each area with evidence before adding it to a profile.", + }; + } + } + + const ranked = rankCourseMatches(course); + const top = ranked[0]; + const runnerUp = ranked[1]; + if ((top.score >= 0.72 && top.score - runnerUp.score >= 0.06) || (top.score >= 0.44 && top.score - runnerUp.score >= 0.2)) { + return { + status: "matched", + course, + families: [top], + skills: mergeSkills([top]), + basis: "Suggested from the course title only. Verify each area with evidence before adding it to a profile.", + }; + } + + if (top.score >= 0.48) { + return { status: "ambiguous", course, suggestions: ranked.slice(0, 3), families: [] }; + } + + return { status: "unknown", course, suggestions: [], families: COURSE_FAMILIES }; +} diff --git a/landing-page/src/styles.css b/landing-page/src/styles.css index c21179b..0c48490 100644 --- a/landing-page/src/styles.css +++ b/landing-page/src/styles.css @@ -6,945 +6,179 @@ font-family: "DM Sans", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; - --navy: #0c1938; - --blue: #2457e6; - --blue-dark: #123fbf; - --sky: #eaf0ff; + --navy: #10234d; + --blue: #1e5bff; + --blue-dark: #1747c9; + --sky: #eef4ff; + --focus: #7ea3ff; --line: #dbe3f6; --muted: #65708a; - --white: #ffffff; + --white: #fff; + --danger: #9a2d25; --shadow: 0 24px 64px rgba(21, 50, 120, 0.15); } -* { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { margin: 0; min-width: 320px; - background: - radial-gradient(circle at 8% 9%, rgba(65, 115, 255, 0.08), transparent 26rem), - #f7f9ff; -} - -button, -input { - font: inherit; -} - -button, -a { - -webkit-tap-highlight-color: transparent; + background: radial-gradient(circle at 8% 9%, rgba(65, 115, 255, 0.08), transparent 26rem), #f7f9ff; } +button, input { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +a { color: inherit; } +h1, h2, h3, h4 { margin: 0; font-family: "Manrope", sans-serif; letter-spacing: -0.045em; } -button { - cursor: pointer; -} - -a { - color: inherit; -} - -.skip-link { - position: fixed; - left: 1rem; - top: -5rem; - z-index: 1000; - padding: 0.75rem 1rem; - background: var(--navy); - color: white; - border-radius: 0.6rem; -} - -.skip-link:focus { - top: 1rem; -} +.skip-link { position: fixed; left: 1rem; top: -5rem; z-index: 1000; padding: .75rem 1rem; border-radius: .6rem; background: var(--navy); color: white; } +.skip-link:focus { top: 1rem; } .site-header { - position: sticky; - top: 0; - z-index: 30; - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - gap: 2rem; - width: min(1180px, calc(100% - 2rem)); - min-height: 74px; - margin: 1rem auto 0; - padding: 0 1rem 0 1.25rem; - border: 1px solid rgba(219, 227, 246, 0.86); - border-radius: 1.1rem; - background: rgba(255, 255, 255, 0.88); - box-shadow: 0 12px 30px rgba(24, 52, 110, 0.07); - backdrop-filter: blur(18px); -} - -.brand { - display: inline-flex; - align-items: center; - gap: 0.6rem; - width: fit-content; - font: 800 1.24rem/1 "Manrope", sans-serif; - text-decoration: none; -} - -.brand img { - display: block; - object-fit: contain; -} - -.site-header nav { - display: flex; - align-items: center; - gap: 2rem; -} - -.site-header nav a { - color: #44516e; - font-size: 0.92rem; - font-weight: 600; - text-decoration: none; -} - -.site-header nav a:hover { - color: var(--blue); -} - -.header-cta { - justify-self: end; - min-height: 44px; - padding: 0 1.15rem; - border: 0; - border-radius: 0.72rem; - background: var(--blue); - color: white; - font-weight: 700; -} - -.hero { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(360px, 0.8fr); - gap: clamp(3rem, 7vw, 6.4rem); - align-items: center; - width: min(1120px, calc(100% - 3rem)); - min-height: calc(100vh - 92px); - margin: 0 auto; - padding: 5rem 0 6rem; -} - -.launch-pill { - display: inline-flex; - align-items: center; - gap: 0.58rem; - padding: 0.46rem 0.7rem; - border: 1px solid #cedbff; - border-radius: 999px; - background: rgba(255, 255, 255, 0.8); - color: #3157b7; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.02em; -} - -.launch-pill span { - width: 0.48rem; - height: 0.48rem; - border-radius: 50%; - background: #2b63f1; - box-shadow: 0 0 0 5px rgba(43, 99, 241, 0.12); -} - -h1, -h2, -h3 { - margin: 0; - font-family: "Manrope", sans-serif; - letter-spacing: -0.045em; -} - -h1 { - max-width: 760px; - margin-top: 1.45rem; - font-size: clamp(3.1rem, 5.7vw, 5.6rem); - line-height: 0.99; -} - -h1 em { - color: var(--blue); - font-style: normal; -} - -.hero-intro { - max-width: 665px; - margin: 1.5rem 0 0; - color: #52617f; - font-size: clamp(1rem, 1.6vw, 1.12rem); - line-height: 1.73; -} - -.proof-list { - display: grid; - gap: 0.8rem; - margin-top: 2rem; -} - -.proof-list div { - display: flex; - align-items: center; - gap: 0.85rem; -} - -.proof-list span { - display: grid; - place-items: center; - width: 2rem; - height: 2rem; - flex: 0 0 auto; - border-radius: 0.58rem; - background: #e5ecff; - color: var(--blue); - font: 800 0.74rem/1 "Manrope", sans-serif; -} - -.proof-list p { - margin: 0; - color: #26334f; - font-size: 0.93rem; - font-weight: 600; -} - -.score-note { - display: flex; - align-items: center; - gap: 0.82rem; - width: fit-content; - margin-top: 1.6rem; - padding: 0.65rem 0.8rem 0.65rem 0.66rem; - border: 1px solid #d7e0f8; - border-radius: 0.78rem; - background: rgba(255, 255, 255, 0.74); - color: #71809d; - font-size: 0.76rem; -} - -.score-note strong { - padding: 0.34rem 0.55rem; - border-radius: 999px; - background: var(--navy); - color: white; - font-size: 0.66rem; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.signup-card { - position: relative; - overflow: hidden; - padding: clamp(2rem, 4vw, 3.3rem); - border: 1px solid rgba(255, 255, 255, 0.36); - border-radius: 1.65rem; - background: - linear-gradient(150deg, rgba(255, 255, 255, 0.13), transparent 46%), - var(--blue); - box-shadow: var(--shadow); - color: white; -} - -.card-orbit { - position: absolute; - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 50%; - pointer-events: none; -} - -.orbit-one { - width: 20rem; - height: 20rem; - right: -12rem; - top: -9rem; -} - -.orbit-two { - width: 13rem; - height: 13rem; - right: -8rem; - top: -4.3rem; -} - -.eyebrow { - color: #86a7ff; - font-size: 0.68rem; - font-weight: 800; - letter-spacing: 0.14em; -} - -.signup-card .eyebrow, -.closing-section .eyebrow { - color: #c5d4ff; -} - -.signup-card h2 { - margin-top: 0.65rem; - font-size: clamp(2rem, 3.4vw, 3rem); -} - -.signup-card > p { - margin: 0.8rem 0 0; - color: #d8e3ff; - line-height: 1.55; -} - -.field-preview { - display: grid; - gap: 0.7rem; - margin: 1.65rem 0; -} - -.field-preview div { - display: grid; - gap: 0.42rem; -} - -.field-preview span { - color: #dae4ff; - font-size: 0.72rem; - font-weight: 600; -} - -.field-preview i { - height: 2.8rem; - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 0.65rem; - background: rgba(255, 255, 255, 0.1); -} - -.primary-button, -.qualification-row button, -.light-button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.8rem; - min-height: 50px; - border: 0; - border-radius: 0.75rem; - font-weight: 800; -} - -.primary-button { - width: 100%; - background: white; - color: var(--blue-dark); -} - -.primary-button span, -.light-button span { - font-size: 1.25rem; -} - -.signup-card .privacy-note { - margin-top: 0.85rem; - color: #c4d3f8; - font-size: 0.72rem; - text-align: center; -} - -.taster-section { - padding: clamp(5rem, 9vw, 8rem) max(1.5rem, calc((100% - 1120px) / 2)); - background: white; -} - -.section-heading { - max-width: 700px; - margin: 0 auto 3rem; - text-align: center; -} - -.section-heading h2 { - margin-top: 0.7rem; - font-size: clamp(2.3rem, 4.3vw, 4rem); -} - -.section-heading p { - max-width: 620px; - margin: 1rem auto 0; - color: var(--muted); - line-height: 1.7; -} - -.taster-shell { - display: grid; - grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); - min-height: 440px; - overflow: hidden; - border: 1px solid var(--line); - border-radius: 1.5rem; - background: #f6f8fe; - box-shadow: 0 26px 65px rgba(33, 61, 125, 0.1); -} - -.taster-form { - padding: clamp(2rem, 4.6vw, 4rem); - border-right: 1px solid var(--line); - background: white; -} - -.taster-form label { - display: block; - margin-bottom: 0.6rem; - font-weight: 800; -} - -.qualification-row { - display: grid; - grid-template-columns: 1fr auto; - gap: 0.6rem; -} - -.qualification-row input { - min-width: 0; - height: 50px; - padding: 0 1rem; - border: 1px solid #cdd8ef; - border-radius: 0.72rem; - outline: none; -} - -.qualification-row input:focus { - border-color: var(--blue); - box-shadow: 0 0 0 4px rgba(36, 87, 230, 0.12); -} - -.qualification-row button { - padding: 0 1.2rem; - background: var(--navy); - color: white; -} - -.preset-list { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - margin-top: 1.3rem; -} - -.preset-list button { - min-height: 38px; - padding: 0.45rem 0.78rem; - border: 1px solid #d9e1f1; - border-radius: 999px; - background: #f8faff; - color: #53617c; - font-size: 0.78rem; - font-weight: 700; -} - -.preset-list button:hover, -.preset-list button.selected { - border-color: #9db6fa; - background: #eaf0ff; - color: var(--blue-dark); -} - -.profile-preview { - display: grid; - align-content: center; - padding: clamp(2rem, 4.6vw, 4rem); - background: - linear-gradient(rgba(255, 255, 255, 0.66), rgba(255, 255, 255, 0.66)), - radial-gradient(circle at 85% 10%, #bed0ff, transparent 16rem); -} - -.empty-profile { - max-width: 360px; - margin: auto; - text-align: center; -} - -.profile-spark { - display: grid; - place-items: center; - width: 4rem; - height: 4rem; - margin: 0 auto 1.2rem; - border-radius: 1rem; - background: #e4ebff; - color: var(--blue); - font-size: 1.6rem; - transform: rotate(8deg); -} - -.empty-profile h3 { - font-size: 1.45rem; -} - -.empty-profile p, -.preview-footnote { - color: var(--muted); - line-height: 1.6; -} - -.preview-topline { - display: flex; - align-items: start; - justify-content: space-between; - gap: 1rem; -} - -.preview-topline h3 { - margin-top: 0.4rem; - font-size: clamp(1.7rem, 3vw, 2.55rem); -} - -.emerging-tag { - padding: 0.4rem 0.66rem; - border-radius: 999px; - background: var(--navy); - color: white; - font-size: 0.65rem; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; -} - -.preview-label { - margin: 2rem 0 0.65rem; - color: #62708b; - font-size: 0.75rem; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.skill-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.65rem; -} - -.skill-grid div { - display: flex; - align-items: center; - gap: 0.7rem; - min-height: 58px; - padding: 0.75rem; - border: 1px solid #d7e0f3; - border-radius: 0.75rem; - background: rgba(255, 255, 255, 0.83); - font-size: 0.84rem; - font-weight: 700; - animation: reveal 0.38s ease both; - animation-delay: var(--delay); -} - -.skill-grid span { - color: var(--blue); - font: 800 0.65rem/1 "Manrope", sans-serif; -} - -.preview-footnote { - margin: 1rem 0 0; - font-size: 0.72rem; -} - -.closing-section { - display: flex; - align-items: center; - justify-content: space-between; - gap: 2rem; - padding: clamp(4rem, 8vw, 7rem) max(1.5rem, calc((100% - 1120px) / 2)); - background: - radial-gradient(circle at 80% 10%, rgba(76, 118, 255, 0.28), transparent 27rem), - var(--navy); - color: white; -} - -.closing-section h2 { - max-width: 720px; - margin-top: 0.65rem; - font-size: clamp(2.5rem, 5vw, 4.7rem); -} - -.closing-section p { - margin: 1rem 0 0; - color: #b9c6e3; -} - -.light-button { - flex: 0 0 auto; - padding: 0 1.4rem; - background: white; - color: var(--navy); -} - -footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 2rem max(1.5rem, calc((100% - 1120px) / 2)); - background: #071128; - color: #aebbd7; -} - -.footer-brand { - color: white; -} - -footer p { - margin: 0; - font-size: 0.82rem; -} - -footer button { - border: 0; - background: transparent; - color: #d9e3fb; - font-weight: 700; -} - -.modal-backdrop { - position: fixed; - inset: 0; - z-index: 100; - display: grid; - place-items: center; - padding: 1rem; - background: rgba(7, 17, 40, 0.72); - backdrop-filter: blur(8px); - animation: fade-in 0.18s ease; -} - -.waitlist-modal { - display: flex; - flex-direction: column; - width: min(720px, 100%); - max-height: min(860px, calc(100vh - 2rem)); - overflow: hidden; - border-radius: 1.25rem; - background: white; - box-shadow: 0 35px 90px rgba(0, 0, 0, 0.34); -} - -.modal-heading { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 1.2rem 1.3rem 1rem; - border-bottom: 1px solid var(--line); -} - -.modal-heading h2 { - margin-top: 0.3rem; - font-size: 1.45rem; -} - -.icon-button { - display: grid; - place-items: center; - width: 42px; - height: 42px; - border: 0; - border-radius: 50%; - background: #eef2fc; - color: var(--navy); - font-size: 1.8rem; - line-height: 1; -} - -.waitlist-form { - display: grid; - gap: 0.95rem; - overflow-y: auto; - padding: 1.25rem 1.3rem 1.4rem; - background: - radial-gradient(circle at 96% 0%, rgba(36, 87, 230, 0.12), transparent 12rem), - #f8faff; -} - -.waitlist-intro { - margin: 0 0 0.2rem; - color: #52617f; - line-height: 1.55; -} - -.waitlist-form label { - display: grid; - gap: 0.42rem; - color: #24314c; - font-size: 0.86rem; - font-weight: 800; -} - -.waitlist-form input { - width: 100%; - min-height: 48px; - padding: 0 0.9rem; - border: 1px solid #ccd8f0; - border-radius: 0.72rem; - background: white; - color: var(--navy); - outline: none; -} - -.waitlist-form input:focus { - border-color: var(--blue); - box-shadow: 0 0 0 4px rgba(36, 87, 230, 0.12); -} - -.submit-button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 50px; - border: 0; - border-radius: 0.75rem; - background: var(--blue); - color: white; - font-weight: 800; -} - -.submit-button:disabled { - cursor: wait; - opacity: 0.7; -} - -.form-status { - margin: 0; - padding: 0.78rem 0.9rem; - border-radius: 0.72rem; - font-size: 0.82rem; - font-weight: 700; - line-height: 1.45; -} - -.form-status.success { - border: 1px solid #bfe8cf; - background: #effbf3; - color: #176335; -} - -.form-status.error { - border: 1px solid #f2b8b5; - background: #fff3f1; - color: #9a2d25; -} - -.form-fallback { - margin: 0; - padding: 0.8rem 1rem; - border-top: 1px solid var(--line); - color: var(--muted); - font-size: 0.75rem; - text-align: center; -} - -.form-fallback a { - color: var(--blue); - font-weight: 700; -} - -.signup-card .waitlist-card-form { - gap: 0.7rem; - margin: 1.65rem 0 0; - padding: 0; - overflow: visible; - background: transparent; -} - -.signup-card .waitlist-card-form label { - color: #dae4ff; - font-size: 0.72rem; - font-weight: 600; -} - -.signup-card .waitlist-card-form input { - min-height: 2.8rem; - border-color: rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.1); - color: white; -} - -.signup-card .waitlist-card-form input:focus { - border-color: rgba(255, 255, 255, 0.7); - box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.16); -} - -.signup-card .waitlist-card-form .submit-button { - width: 100%; - margin-top: 0.75rem; - background: white; - color: var(--blue-dark); -} - -.signup-card .waitlist-card-form .form-status { - margin-top: 0.1rem; - font-size: 0.78rem; -} - -.signup-card .waitlist-card-form .form-status a { - font-weight: 800; -} - -button:focus-visible, -a:focus-visible, -input:focus-visible { - outline: 3px solid #ffbd3e; - outline-offset: 3px; -} - -@keyframes reveal { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } -} - -@media (max-width: 900px) { - .site-header { - grid-template-columns: 1fr auto; - } - - .site-header nav { - display: none; - } - - .hero { - grid-template-columns: 1fr; - min-height: 0; - padding-top: 4rem; - } - - .signup-card { - max-width: 620px; - } - - .taster-shell { - grid-template-columns: 1fr; - } - - .taster-form { - border-right: 0; - border-bottom: 1px solid var(--line); - } - - .profile-preview { - min-height: 380px; - } -} - -@media (max-width: 620px) { - .site-header { - width: calc(100% - 1rem); - min-height: 64px; - margin-top: 0.5rem; - padding: 0 0.55rem 0 0.8rem; - border-radius: 0.9rem; - } - - .brand { - font-size: 1.05rem; - } - - .brand img { - width: 32px; - height: 32px; - } - - .header-cta { - min-height: 40px; - padding: 0 0.85rem; - font-size: 0.78rem; - } - - .hero { - width: min(100% - 2rem, 520px); - gap: 2.6rem; - padding: 3rem 0 4.5rem; - } - - h1 { - font-size: clamp(2.75rem, 13vw, 4rem); - } - - .hero-intro { - line-height: 1.62; - } - - .score-note { - align-items: flex-start; - width: 100%; - } - - .score-note span { - line-height: 1.4; - } - - .signup-card { - padding: 1.65rem; - border-radius: 1.2rem; - } - - .qualification-row { - grid-template-columns: 1fr; - } - - .qualification-row button { - width: 100%; - } - - .skill-grid { - grid-template-columns: 1fr; - } - - .closing-section { - align-items: flex-start; - flex-direction: column; - } - - .light-button { - width: 100%; - } - - footer { - align-items: flex-start; - flex-direction: column; - } - - .waitlist-modal { - height: calc(100vh - 1rem); - border-radius: 1rem; - } - - .modal-backdrop { - padding: 0.5rem; - } + position: sticky; top: 0; z-index: 30; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; + gap: 2rem; width: min(1180px, calc(100% - 2rem)); min-height: 74px; margin: 1rem auto 0; padding: 0 1rem 0 1.25rem; + border: 1px solid rgba(219, 227, 246, .86); border-radius: 1.1rem; background: rgba(255, 255, 255, .9); + box-shadow: 0 12px 30px rgba(24, 52, 110, .07); backdrop-filter: blur(18px); +} +.brand { display: inline-flex; align-items: center; gap: .6rem; width: fit-content; font: 800 1.24rem/1 "Manrope", sans-serif; text-decoration: none; } +.brand img { display: block; object-fit: contain; } +.site-header nav { display: flex; align-items: center; gap: 2rem; } +.site-header nav a { color: #44516e; font-size: .92rem; font-weight: 600; text-decoration: none; } +.site-header nav a:hover { color: var(--blue); } +.header-cta { justify-self: end; min-height: 44px; padding: 0 1.15rem; border: 0; border-radius: .72rem; background: var(--blue); color: white; font-weight: 700; } + +.hero { display: grid; grid-template-columns: minmax(0, 1.12fr) minmax(380px, .88fr); gap: clamp(3rem, 7vw, 6rem); align-items: center; width: min(1120px, calc(100% - 3rem)); margin: 0 auto; padding: 5.5rem 0 6rem; } +.launch-pill { display: inline-flex; align-items: center; gap: .58rem; padding: .46rem .7rem; border: 1px solid #cedbff; border-radius: 999px; background: rgba(255,255,255,.8); color: #3157b7; font-size: .78rem; font-weight: 700; } +.launch-pill span { width: .48rem; height: .48rem; border-radius: 50%; background: #2b63f1; box-shadow: 0 0 0 5px rgba(43,99,241,.12); } +h1 { max-width: 760px; margin-top: 1.45rem; font-size: clamp(3.1rem, 5.7vw, 5.5rem); line-height: .99; } +h1 em { color: var(--blue); font-style: normal; } +.hero-intro { max-width: 665px; margin: 1.5rem 0 0; color: #52617f; font-size: clamp(1rem, 1.6vw, 1.12rem); line-height: 1.72; } +.proof-list { display: grid; gap: .8rem; margin-top: 2rem; } +.proof-list div { display: flex; align-items: center; gap: .85rem; } +.proof-list span { display: grid; place-items: center; width: 2rem; height: 2rem; flex: 0 0 auto; border-radius: .58rem; background: #e5ecff; color: var(--blue); font: 800 .65rem/1 "Manrope", sans-serif; } +.proof-list p { margin: 0; color: #26334f; font-size: .93rem; font-weight: 600; } +.score-note { display: flex; align-items: center; gap: .82rem; width: fit-content; margin-top: 1.6rem; padding: .65rem .8rem .65rem .66rem; border: 1px solid #d7e0f8; border-radius: .78rem; background: rgba(255,255,255,.74); color: #71809d; font-size: .76rem; } +.score-note strong { padding: .34rem .55rem; border-radius: 999px; background: var(--navy); color: white; font-size: .66rem; letter-spacing: .04em; text-transform: uppercase; } + +.signup-card { scroll-margin-top: 7rem; overflow: hidden; border: 1px solid rgba(255,255,255,.55); border-radius: 1.65rem; background: linear-gradient(145deg, #1e5bff 0%, #315ee0 58%, #3b82f6 100%); box-shadow: 0 24px 64px rgba(30, 91, 255, .2); } +.signup-card-copy { position: relative; overflow: hidden; padding: 2.15rem 2.15rem 1.75rem; color: white; } +.signup-card-copy::after { content: ""; position: absolute; width: 15rem; height: 15rem; right: -8rem; top: -8rem; border: 1px solid rgba(255,255,255,.16); border-radius: 50%; } +.eyebrow { color: #5f82e3; font-size: .68rem; font-weight: 800; letter-spacing: .14em; } +.signup-card .eyebrow, .closing-section .eyebrow { color: #dce8ff; } +.signup-card h2 { margin-top: .6rem; font-size: clamp(2rem, 3.2vw, 2.7rem); } +.signup-card-copy p { margin: .75rem 0 0; color: #edf4ff; line-height: 1.55; } +.waitlist-panel { margin: 0 .55rem .55rem; padding: 1.55rem; border-radius: 1.25rem; background: #fbfcff; box-shadow: 0 -8px 24px rgba(7,17,40,.12); } +.waitlist-form { display: grid; gap: .8rem; } +.waitlist-form label { display: grid; gap: .36rem; color: #24314c; font-size: .78rem; font-weight: 800; } +.waitlist-form label span i { color: var(--muted); font-size: .68rem; font-style: normal; font-weight: 600; } +.waitlist-form input:not([type="checkbox"]) { width: 100%; min-height: 46px; padding: 0 .85rem; border: 1px solid #ccd8f0; border-radius: .68rem; background: white; color: var(--navy); outline: none; } +.waitlist-form input:not([type="checkbox"]):focus { border-color: var(--blue); box-shadow: 0 0 0 4px rgba(36,87,230,.12); } +.waitlist-form input[aria-invalid="true"] { border-color: #d16a61; } +.waitlist-form small, .standalone-error { color: var(--danger); font-size: .7rem; line-height: 1.35; } +.consent-field { grid-template-columns: auto 1fr; align-items: start; gap: .65rem !important; margin-top: .15rem; line-height: 1.45; } +.consent-field input { width: 1.05rem; height: 1.05rem; margin: .15rem 0 0; accent-color: var(--blue); } +.privacy-summary { margin: -.15rem 0 .1rem; color: var(--muted); font-size: .69rem; line-height: 1.48; } +.privacy-summary a { color: var(--blue-dark); font-weight: 700; } +.honeypot { position: absolute !important; left: -10000px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; } +.submit-button { display: inline-flex; align-items: center; justify-content: center; min-height: 49px; border: 0; border-radius: .72rem; background: var(--blue); color: white; font-weight: 800; } +.submit-button:disabled { cursor: wait; opacity: .68; } +.error-summary, .form-status { margin: 0; padding: .72rem .82rem; border-radius: .68rem; font-size: .76rem; font-weight: 700; line-height: 1.45; } +.error-summary, .form-status.error { border: 1px solid #f2b8b5; background: #fff3f1; color: var(--danger); } +.form-status a { font-weight: 800; } +.waitlist-success { padding: 1rem .2rem 1.2rem; } +.waitlist-success > span { display: inline-block; padding: .35rem .58rem; border-radius: 999px; background: #e6f8ef; color: #176335; font-size: .66rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; } +.waitlist-success h3 { margin-top: .9rem; font-size: 1.65rem; } +.waitlist-success p { margin: .65rem 0 0; color: var(--muted); line-height: 1.6; } + +.taster-section { padding: clamp(5rem, 9vw, 8rem) max(1.5rem, calc((100% - 1120px) / 2)); background: white; } +.section-heading { max-width: 720px; margin: 0 auto 3rem; text-align: center; } +.section-heading h2 { margin-top: .7rem; font-size: clamp(2.3rem, 4.3vw, 4rem); } +.section-heading p { max-width: 620px; margin: 1rem auto 0; color: var(--muted); line-height: 1.7; } +.taster-shell { display: grid; grid-template-columns: minmax(0,.92fr) minmax(0,1.08fr); min-height: 480px; overflow: hidden; border: 1px solid var(--line); border-radius: 1.5rem; background: #f6f8fe; box-shadow: 0 26px 65px rgba(33,61,125,.1); } +.taster-form { padding: clamp(2rem,4.6vw,4rem); border-right: 1px solid var(--line); background: white; } +.taster-form > label { display: block; margin-bottom: .6rem; font-weight: 800; } +.qualification-row { display: grid; grid-template-columns: 1fr auto; gap: .6rem; } +.qualification-row input { min-width: 0; height: 50px; padding: 0 1rem; border: 1px solid #cdd8ef; border-radius: .72rem; outline: none; } +.qualification-row input:focus { border-color: var(--blue); box-shadow: 0 0 0 4px rgba(36,87,230,.12); } +.qualification-row button, .profile-join { min-height: 50px; padding: 0 1.2rem; border: 0; border-radius: .75rem; background: var(--navy); color: white; font-weight: 800; } +.field-hint { margin: .7rem 0 0; color: var(--muted); font-size: .72rem; line-height: 1.45; } +.preset-list { display: flex; flex-wrap: wrap; gap: .6rem; margin-top: 1.3rem; } +.preset-list button, .choice-list button, .family-list button { min-height: 38px; padding: .45rem .78rem; border: 1px solid #d9e1f1; border-radius: 999px; background: #f8faff; color: #53617c; font-size: .76rem; font-weight: 700; } +.preset-list button:hover, .preset-list button.selected, .choice-list button:hover, .family-list button:hover { border-color: #9db6fa; background: #eaf0ff; color: var(--blue-dark); } +.profile-preview { display: grid; align-content: center; padding: clamp(2rem,4vw,3.5rem); background: linear-gradient(rgba(255,255,255,.72),rgba(255,255,255,.72)), radial-gradient(circle at 85% 10%,#bed0ff,transparent 16rem); } +.empty-profile { max-width: 360px; margin: auto; text-align: center; } +.empty-profile img { padding: .55rem; border-radius: 1rem; background: #e4ebff; transform: rotate(4deg); } +.empty-profile h3 { margin-top: 1.15rem; font-size: 1.45rem; } +.empty-profile p, .preview-footnote, .match-help p { color: var(--muted); line-height: 1.6; } +.preview-topline { display: flex; align-items: start; justify-content: space-between; gap: 1rem; } +.preview-topline h3 { margin-top: .4rem; font-size: clamp(1.65rem,3vw,2.45rem); } +.preview-topline p { margin: .4rem 0 0; color: var(--muted); font-size: .75rem; } +.emerging-tag { padding: .4rem .66rem; border-radius: 999px; background: var(--navy); color: white; font-size: .63rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; } +.preview-label { margin: 1.6rem 0 .7rem; color: #62708b; font-size: .72rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.skill-groups { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: .6rem; } +.skill-groups section { padding: .8rem; border: 1px solid #d7e0f3; border-radius: .8rem; background: rgba(255,255,255,.84); } +.skill-groups h4 { margin-bottom: .55rem; color: var(--blue-dark); font-size: .67rem; letter-spacing: .04em; text-transform: uppercase; } +.skill-grid { display: grid; gap: .42rem; } +.microskill-card { display: grid; grid-template-columns: auto 1fr; gap: .55rem; align-items: start; padding: .62rem; border: 1px solid #e0e6f3; border-radius: .65rem; background: rgba(255,255,255,.76); cursor: pointer; } +.microskill-card:has(input:checked) { border-color: #9db6fa; background: #eef4ff; box-shadow: 0 0 0 2px rgba(126,163,255,.16); } +.microskill-card input { width: 1rem; height: 1rem; margin: .12rem 0 0; accent-color: var(--blue); } +.microskill-card span { display: block; color: #26334f; line-height: 1.3; } +.microskill-card strong { font-size: .75rem; } +.preview-footnote { margin: .9rem 0 0; font-size: .7rem; } +.profile-actions { display: flex; flex-wrap: wrap; gap: .55rem; margin-top: 1rem; } +.profile-join, .profile-secondary { min-height: 42px; padding: 0 .85rem; border-radius: .68rem; font-size: .72rem; font-weight: 800; } +.profile-join { background: var(--blue); } +.profile-secondary { border: 1px solid #c8d5ef; background: white; color: var(--blue-dark); } +.match-help h3 { margin-top: .55rem; font-size: 2rem; } +.choice-list, .family-list { display: flex; flex-wrap: wrap; gap: .55rem; margin-top: 1.2rem; } +.family-list button { border-radius: .65rem; } + +.early-access-section { display: grid; grid-template-columns: minmax(0,.8fr) minmax(0,1.2fr); gap: clamp(3rem,8vw,7rem); padding: clamp(5rem,9vw,7rem) max(1.5rem,calc((100% - 1120px) / 2)); background: #f7f9ff; } +.early-access-section h2 { max-width: 470px; margin-top: .65rem; font-size: clamp(2.3rem,4vw,3.7rem); } +.faq-list { display: grid; gap: .75rem; } +.faq-list details { padding: 1rem 1.15rem; border: 1px solid var(--line); border-radius: .85rem; background: white; } +.faq-list summary { cursor: pointer; color: var(--navy); font-weight: 800; } +.faq-list p { margin: .75rem 0 0; color: var(--muted); line-height: 1.65; } + +.closing-section { display: flex; align-items: center; justify-content: space-between; gap: 2rem; padding: clamp(4rem,8vw,7rem) max(1.5rem,calc((100% - 1120px) / 2)); background: radial-gradient(circle at 80% 10%,rgba(76,118,255,.28),transparent 27rem),var(--navy); color: white; } +.closing-section h2 { max-width: 720px; margin-top: .65rem; font-size: clamp(2.5rem,5vw,4.7rem); } +.closing-section p { margin: 1rem 0 0; color: #b9c6e3; } +.light-button { flex: 0 0 auto; min-height: 50px; padding: 0 1.4rem; border: 0; border-radius: .75rem; background: white; color: var(--navy); font-weight: 800; } +footer { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 2rem max(1.5rem,calc((100% - 1120px) / 2)); background: #071128; color: #aebbd7; } +.footer-brand { color: white; } +footer p { margin: 0; font-size: .82rem; } +footer button { border: 0; background: transparent; color: #d9e3fb; font-weight: 700; } + +button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } + +@media (max-width: 960px) { + .site-header { grid-template-columns: 1fr auto; } + .site-header nav { display: none; } + .hero { grid-template-columns: 1fr; } + .signup-card { max-width: 650px; } + .taster-shell, .early-access-section { grid-template-columns: 1fr; } + .taster-form { border-right: 0; border-bottom: 1px solid var(--line); } + .profile-preview { min-height: 430px; } +} + +@media (max-width: 640px) { + .site-header { width: calc(100% - 1rem); min-height: 64px; margin-top: .5rem; padding: 0 .55rem 0 .8rem; border-radius: .9rem; } + .brand { font-size: 1.05rem; } + .brand img { width: 32px; height: 32px; } + .header-cta { min-height: 40px; padding: 0 .85rem; font-size: .78rem; } + .hero { width: min(100% - 2rem,520px); gap: 2.8rem; padding: 3.2rem 0 4.5rem; } + h1 { font-size: clamp(2.75rem,13vw,4rem); } + .score-note { align-items: flex-start; width: 100%; } + .signup-card-copy { padding: 1.6rem 1.4rem 1.35rem; } + .waitlist-panel { margin: 0 .35rem .35rem; padding: 1.25rem 1rem; } + .qualification-row { grid-template-columns: 1fr; } + .qualification-row button { width: 100%; } + .skill-groups { grid-template-columns: 1fr; } + .closing-section, footer { align-items: flex-start; flex-direction: column; } + .light-button { width: 100%; } } @media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - } + html { scroll-behavior: auto; } + *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; } } diff --git a/landing-page/tests/course-classifier.test.mjs b/landing-page/tests/course-classifier.test.mjs new file mode 100644 index 0000000..c6a3758 --- /dev/null +++ b/landing-page/tests/course-classifier.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + classifyCourse, + normalizeCourse, + profileForFamily, + rankCourseMatches, +} from "../src/courseClassifier.js"; + +test("normalizes case, punctuation and common degree awards", () => { + assert.equal(normalizeCourse("B.Sc. (Hons) COMPUTER SCIENCE"), "computer"); + assert.equal(normalizeCourse("Bachelor of Engineering - Mechanical Engineering"), "engineering mechanical engineering"); +}); + +test("matches common courses without relying on case", () => { + const result = classifyCourse("bsc COMPUTER SCIENCE"); + assert.equal(result.status, "matched"); + assert.equal(result.families[0].id, "computing"); + assert.equal(Object.values(result.skills).flat().length, 6); +}); + +test("handles a minor spelling error", () => { + const result = classifyCourse("Computr Science"); + assert.equal(result.status, "matched"); + assert.equal(result.families[0].id, "computing"); +}); + +test("merges two distinct families for a joint course", () => { + const result = classifyCourse("Computer Science and Economics"); + assert.equal(result.status, "matched"); + assert.deepEqual(result.families.map((family) => family.id), ["computing", "business-economics"]); + assert.equal(new Set(Object.values(result.skills).flat()).size, 6); +}); + +test("returns ranked choices instead of inventing a generic profile", () => { + const result = classifyCourse("media policy"); + assert.equal(result.status, "ambiguous"); + assert.ok(result.suggestions.length <= 3); +}); + +test("asks for a family when the title is unknown", () => { + const result = classifyCourse("quantum basket weaving"); + assert.equal(result.status, "unknown"); + assert.equal(result.families.length, 12); +}); + +test("can build a verified family choice without changing the typed course", () => { + const result = profileForFamily("built-environment", "Property development practice"); + assert.equal(result.course, "Property development practice"); + assert.equal(result.families[0].id, "built-environment"); +}); + +test("ranks exact aliases above approximate matches", () => { + const [top] = rankCourseMatches("Microbiology"); + assert.equal(top.id, "life-health"); + assert.equal(top.score, 1); +}); diff --git a/landing-page/tests/sites-worker.test.mjs b/landing-page/tests/sites-worker.test.mjs index 8ba1bbe..4a80a7f 100644 --- a/landing-page/tests/sites-worker.test.mjs +++ b/landing-page/tests/sites-worker.test.mjs @@ -1,7 +1,25 @@ import assert from "node:assert/strict"; import { access } from "node:fs/promises"; import test from "node:test"; -import worker from "../worker/index.js"; +import worker, { + handleWaitlist, + normalizeWhatsApp, + resetRateLimitsForTests, + validateWaitlist, +} from "../worker/index.js"; + +function validPayload(overrides = {}) { + return { + firstName: "Ada", + email: "ada@example.com", + whatsapp: "+234 801 234 5678", + university: "University of Lagos", + course: "Computer Science", + consent: true, + website: "", + ...overrides, + }; +} test("permanently redirects product connector paths to canonical domains", async () => { const cases = [ @@ -105,6 +123,96 @@ test("does not turn missing API or write requests into the app shell", async () } }); +test("validates and normalizes waitlist details", () => { + const result = validateWaitlist(validPayload({ whatsapp: "0801 234 5678" })); + assert.deepEqual(result.errors, {}); + assert.equal(result.values.whatsapp, "+2348012345678"); + assert.equal(normalizeWhatsApp("+44 (0) 7700 900123"), "+4407700900123"); +}); + +test("returns field errors and does not contact the upstream form", async () => { + let calls = 0; + const request = new Request("https://example.test/api/waitlist", { + method: "POST", + headers: { "content-type": "application/json", origin: "https://example.test" }, + body: JSON.stringify(validPayload({ email: "not-an-email", consent: false })), + }); + const response = await handleWaitlist(request, async () => { + calls += 1; + return new Response(null, { status: 200 }); + }); + const body = await response.json(); + assert.equal(response.status, 400); + assert.ok(body.errors.email); + assert.ok(body.errors.consent); + assert.equal(calls, 0); +}); + +test("forwards accepted details to the existing Google Form and verifies its response", async () => { + let forwarded; + const request = new Request("https://example.test/api/waitlist", { + method: "POST", + headers: { "content-type": "application/json", origin: "https://example.test" }, + body: JSON.stringify(validPayload()), + }); + const response = await handleWaitlist(request, async (_url, options) => { + forwarded = options.body; + return new Response("accepted", { status: 200 }); + }); + assert.equal(response.status, 201); + assert.equal(forwarded.get("entry.1211410083"), "Ada"); + assert.equal(forwarded.get("entry.1093468283"), "ada@example.com | WhatsApp: +2348012345678"); + assert.equal(forwarded.get("entry.1556195189"), "Computer Science"); +}); + +test("does not claim success when the upstream form fails", async () => { + const request = new Request("https://example.test/api/waitlist", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(validPayload()), + }); + const response = await handleWaitlist(request, async () => new Response("no", { status: 503 })); + assert.equal(response.status, 502); +}); + +test("rejects cross-origin, oversized and non-JSON requests", async () => { + const crossOrigin = await handleWaitlist(new Request("https://example.test/api/waitlist", { + method: "POST", + headers: { "content-type": "application/json", origin: "https://attacker.test" }, + body: JSON.stringify(validPayload()), + })); + assert.equal(crossOrigin.status, 403); + + const wrongType = await handleWaitlist(new Request("https://example.test/api/waitlist", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "hello", + })); + assert.equal(wrongType.status, 415); + + const oversized = await handleWaitlist(new Request("https://example.test/api/waitlist", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: "x".repeat(9000) }), + })); + assert.equal(oversized.status, 413); +}); + +test("throttles repeated submissions when the platform supplies an address", async () => { + resetRateLimitsForTests(); + const fetcher = async () => new Response("accepted", { status: 200 }); + const responses = []; + for (let attempt = 0; attempt < 6; attempt += 1) { + responses.push(await handleWaitlist(new Request("https://example.test/api/waitlist", { + method: "POST", + headers: { "content-type": "application/json", "cf-connecting-ip": "192.0.2.1" }, + body: JSON.stringify(validPayload({ email: `ada${attempt}@example.com` })), + }), fetcher)); + } + assert.equal(responses.at(-1).status, 429); + resetRateLimitsForTests(); +}); + test("emits the files required by Sites packaging", async () => { await access(new URL("../dist/client/index.html", import.meta.url)); await access(new URL("../dist/server/index.js", import.meta.url)); diff --git a/landing-page/worker/index.js b/landing-page/worker/index.js index 3928f55..7b12cf9 100644 --- a/landing-page/worker/index.js +++ b/landing-page/worker/index.js @@ -1,3 +1,14 @@ +const FORM_RESPONSE_URL = + "https://docs.google.com/forms/d/e/1FAIpQLSfphC9GZD2uJR6Ezv7IgX8_R7g6_JC7wOA7qeGBBVAzxBU_Dg/formResponse"; + +const FORM_ENTRIES = { + firstName: "entry.1211410083", + contact: "entry.1093468283", + university: "entry.1721877843", + course: "entry.1556195189", + consent: "entry.755150101", +}; + const PRODUCT_CONNECTORS = new Map([ ["skills", "https://skillscanvas.co"], ["concepts", "https://conceptsnexus.co"], @@ -5,21 +16,131 @@ const PRODUCT_CONNECTORS = new Map([ ["vest", "https://vestden.co"], ]); -function productConnectorRedirect(request) { - if (!["GET", "HEAD"].includes(request.method)) { - return null; +const MAX_BODY_BYTES = 8 * 1024; +const RATE_LIMIT = { max: 5, windowMs: 10 * 60 * 1000 }; +const rateBuckets = new Map(); + +function json(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }, + }); +} + +function trimText(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function normalizeWhatsApp(value) { + const raw = trimText(value); + if (!raw) return ""; + const hasPlus = raw.startsWith("+"); + const digits = raw.replace(/\D/g, ""); + if (digits.length === 11 && digits.startsWith("0")) return `+234${digits.slice(1)}`; + return `${hasPlus ? "+" : ""}${digits}`; +} + +export function validateWaitlist(payload) { + const values = { + firstName: trimText(payload?.firstName), + email: trimText(payload?.email).toLowerCase(), + whatsapp: normalizeWhatsApp(payload?.whatsapp), + university: trimText(payload?.university), + course: trimText(payload?.course), + microskills: trimText(payload?.microskills), + consent: payload?.consent === true, + website: trimText(payload?.website), + }; + const errors = {}; + if (!values.firstName || values.firstName.length > 80) errors.firstName = "Enter a first name of 80 characters or fewer."; + if (!/^\S+@\S+\.\S+$/.test(values.email) || values.email.length > 254) errors.email = "Enter a valid email address."; + if (values.whatsapp && !/^\+?\d{7,15}$/.test(values.whatsapp)) errors.whatsapp = "Enter a valid WhatsApp number or leave it blank."; + if (values.university.length < 2 || values.university.length > 120) errors.university = "Enter a university or campus of 120 characters or fewer."; + if (values.course.length < 2 || values.course.length > 160) errors.course = "Enter a course of study of 160 characters or fewer."; + if (values.microskills.length > 800) errors.microskills = "Choose 800 characters or fewer of microskills."; + if (!values.consent) errors.consent = "Consent is required to join early access."; + return { values, errors }; +} + +function rateLimited(request, now = Date.now()) { + const address = request.headers.get("cf-connecting-ip"); + if (!address) return false; + const current = rateBuckets.get(address); + if (!current || now - current.startedAt >= RATE_LIMIT.windowMs) { + rateBuckets.set(address, { startedAt: now, count: 1 }); + return false; } + current.count += 1; + return current.count > RATE_LIMIT.max; +} + +export function resetRateLimitsForTests() { + rateBuckets.clear(); +} + +export async function handleWaitlist(request, fetchImpl = fetch) { + const origin = request.headers.get("origin"); + if (origin && origin !== new URL(request.url).origin) return json({ error: "Cross-origin submission blocked." }, 403); + + const contentType = request.headers.get("content-type") || ""; + if (!contentType.toLowerCase().startsWith("application/json")) return json({ error: "Use application/json." }, 415); + + const declaredLength = Number(request.headers.get("content-length") || 0); + if (declaredLength > MAX_BODY_BYTES) return json({ error: "Request is too large." }, 413); + if (rateLimited(request)) return json({ error: "Too many attempts. Please try again later." }, 429); + + let rawBody; + let payload; + try { + rawBody = await request.text(); + if (new TextEncoder().encode(rawBody).byteLength > MAX_BODY_BYTES) return json({ error: "Request is too large." }, 413); + payload = JSON.parse(rawBody); + } catch { + return json({ error: "Invalid JSON." }, 400); + } + + const { values, errors } = validateWaitlist(payload); + if (values.website) return json({ accepted: true }, 201); + if (Object.keys(errors).length) return json({ error: "Please check the form.", errors }, 400); + + const formData = new FormData(); + formData.append(FORM_ENTRIES.firstName, values.firstName); + formData.append(FORM_ENTRIES.contact, values.whatsapp ? `${values.email} | WhatsApp: ${values.whatsapp}` : values.email); + formData.append(FORM_ENTRIES.university, values.university); + const courseWithMicroskills = values.microskills ? `${values.course} | Microskills: ${values.microskills}` : values.course; + formData.append(FORM_ENTRIES.course, courseWithMicroskills); + formData.append(FORM_ENTRIES.consent, "I agree"); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 6000); + try { + const upstream = await fetchImpl(FORM_RESPONSE_URL, { + method: "POST", + body: formData, + redirect: "follow", + signal: controller.signal, + }); + if (!upstream.ok) return json({ error: "The waitlist service did not accept the submission." }, 502); + return json({ accepted: true }, 201); + } catch { + return json({ error: "The waitlist service is temporarily unavailable." }, 502); + } finally { + clearTimeout(timeout); + } +} + +function productConnectorRedirect(request) { + if (!["GET", "HEAD"].includes(request.method)) return null; const incomingUrl = new URL(request.url); const [, connector, ...remainder] = incomingUrl.pathname.split("/"); const canonicalOrigin = PRODUCT_CONNECTORS.get(connector); + if (!canonicalOrigin) return null; - if (!canonicalOrigin) { - return null; - } - - // These server-side connector paths are aliases only. Product domains remain - // canonical deployment origins; the suffix and query string cross intact. + // Connector aliases only: product domains remain canonical deployment origins. const destination = new URL(canonicalOrigin); destination.pathname = remainder.length ? `/${remainder.join("/")}` : "/"; destination.search = incomingUrl.search; @@ -28,17 +149,18 @@ function productConnectorRedirect(request) { export default { async fetch(request, env) { - const connectorRedirect = productConnectorRedirect(request); - if (connectorRedirect) { - return connectorRedirect; + const url = new URL(request.url); + if (url.pathname === "/api/waitlist") { + if (request.method !== "POST") return json({ error: "Method not allowed." }, 405); + return handleWaitlist(request); } + const connectorRedirect = productConnectorRedirect(request); + if (connectorRedirect) return connectorRedirect; + const response = await env.ASSETS.fetch(request); const acceptsHtml = request.headers.get("accept")?.includes("text/html"); - - if (response.status !== 404 || !acceptsHtml || !["GET", "HEAD"].includes(request.method)) { - return response; - } + if (response.status !== 404 || !acceptsHtml || !["GET", "HEAD"].includes(request.method)) return response; const indexUrl = new URL(request.url); indexUrl.pathname = "/index.html";