diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json
index 0736849c6f..76d7692695 100644
--- a/front_end/messages/cs.json
+++ b/front_end/messages/cs.json
@@ -1940,5 +1940,48 @@
"yourOrganization": "Vaše organizace",
"yourMessageForm": "Vaše zpráva",
"readFullReport": "Přečíst celou zprávu",
+ "discoverServicesTitle": "Objevte služby",
+ "discoverServicesSubtitle": "Vyberte typ své organizace a začněte",
+ "discoverServicesEnterpriseTitle": "Podnikání",
+ "discoverServicesEnterpriseDescription": "Pro korporace, které spravují tržní rizika, dodavatelské řetězce a strategii.",
+ "discoverServicesGovernmentTitle": "Vláda",
+ "discoverServicesGovernmentDescription": "Pro tvůrce politik navigující změny v AI, obraně, zdravotnictví a pracovních trzích.",
+ "discoverServicesNonProfitTitle": "Nezisková organizace",
+ "discoverServicesNonProfitDescription": "Pro nadace, nevládní organizace a think tanky usilující o společenský dopad.",
+ "discoverServicesAcademiaTitle": "Akademická sféra",
+ "discoverServicesAcademiaDescription": "Pro univerzity a instituce podporující vědecký pokrok.",
+ "workWithMetaculus": "Práce s Metaculus",
+ "selectChallengesYouAreCurrentlyFacing": "Vyberte obtíže, kterým aktuálně čelíte",
+ "typeHere": "Sem napište…",
+ "continue": "Pokračovat",
+ "youHaveUnsavedProgress": "Máte neuložený postup.",
+ "stay": "Zůstat",
+ "stepsLeft": "{count, plural, one {# krok zbývá} few {# kroky zbývají} other {# kroků zbývá}}",
+ "howSoonDoYouNeedForecasts": "Jak rychle potřebujete prognózy?",
+ "soonerThanThreeMonths": "Dříve než za 3 měsíce",
+ "laterThanThreeMonths": "Později než za 3 měsíce",
+ "flexibleUnsure": "Flexibilní / zatím si nejsem jistý",
+ "whoShouldMakeTheForecasts": "Kdo by měl dělat prognózy?",
+ "metaculusPros": "Odborníci Metaculus",
+ "clientExperts": "Vaši interní odborníci",
+ "notSure": "Nejsem si jistý",
+ "privacyQuestionTitle": "Jak důvěrná je tato práce?",
+ "semiConfidentiality": "Nějaká důvěrnost",
+ "fullConfidentiality": "Úplná důvěrnost",
+ "weHaveReceivedYourRequest": "Obdrželi jsme vaši žádost",
+ "backToServices": "Zpět ke službám",
+ "semiConfidentialityDescription": "Organizace skrytá, ale otázky mohou být veřejné",
+ "fullConfidentialityDescription": "Organizace i otázky skryté",
+ "submitYourAnswersTitle": "Odešlete své odpovědi a my se ozveme",
+ "yourNamePlaceholder": "Jan Novák",
+ "emailAddressPlaceholder": "jan.novak@firma.cz",
+ "organizationPlaceholder": "Název organizace",
+ "anyAdditionalComments": "Nějaké další poznámky?",
+ "fieldRequired": "Toto pole je povinné.",
+ "invalidEmail": "Zadejte, prosím, platnou e-mailovou adresu.",
+ "submitting": "Odesílání...",
+ "ourTeamWillBeInContactSoon": "Náš tým se brzy ozve.",
+ "automaticRedirectInSeconds": "Automatické přesměrování za {count, plural, one {# sekundu} few {# sekundy} other {# sekund}}",
+ "errorSubmittingQuiz": "Něco se pokazilo. Zkuste to prosím znovu.",
"thousandsOfOpenQuestions": "20 000+ otevřených otázek"
}
diff --git a/front_end/messages/en.json b/front_end/messages/en.json
index 6190b583a5..34d550aff5 100644
--- a/front_end/messages/en.json
+++ b/front_end/messages/en.json
@@ -1933,5 +1933,48 @@
"yourOrganization": "Your organization",
"yourMessageForm": "Your message",
"readFullReport": "Read full report",
+ "discoverServicesTitle": "Discover Services",
+ "discoverServicesSubtitle": "Select your organization type to start",
+ "discoverServicesEnterpriseTitle": "Enterprise",
+ "discoverServicesEnterpriseDescription": "For corporations managing market risk, supply chains, and strategy.",
+ "discoverServicesGovernmentTitle": "Government",
+ "discoverServicesGovernmentDescription": "For policymakers navigating shifts in AI, defense, health, and labor markets.",
+ "discoverServicesNonProfitTitle": "Non-Profit",
+ "discoverServicesNonProfitDescription": "For foundations, NGOs, and think tanks driving social impact.",
+ "discoverServicesAcademiaTitle": "Academia",
+ "discoverServicesAcademiaDescription": "For universities and institutions advancing scientific discovery.",
+ "workWithMetaculus": "Work with Metaculus",
+ "selectChallengesYouAreCurrentlyFacing": "Select challenges you are currently facing",
+ "typeHere": "Type here…",
+ "continue": "Continue",
+ "youHaveUnsavedProgress": "You have unsaved progress.",
+ "stay": "Stay",
+ "stepsLeft": "{count, plural, one {# step left} other {# steps left}}",
+ "howSoonDoYouNeedForecasts": "How soon do you need forecasts?",
+ "soonerThanThreeMonths": "Sooner than 3 months",
+ "laterThanThreeMonths": "Later than 3 months",
+ "flexibleUnsure": "Flexible / not sure yet",
+ "whoShouldMakeTheForecasts": "Who should make the forecasts?",
+ "metaculusPros": "Metaculus pros",
+ "clientExperts": "Your internal experts",
+ "notSure": "Not sure",
+ "privacyQuestionTitle": "How confidential is this work?",
+ "semiConfidentiality": "Some confidentiality",
+ "fullConfidentiality": "Full confidentiality",
+ "submitYourAnswersTitle": "Submit your answers and we’ll be in touch",
+ "weHaveReceivedYourRequest": "We’ve received your request",
+ "ourTeamWillBeInContactSoon": "Our team will be in contact soon.",
+ "backToServices": "Back to Services",
+ "semiConfidentialityDescription": "Organization hidden but questions can be public",
+ "fullConfidentialityDescription": "Both organization and questions hidden",
+ "yourNamePlaceholder": "John Doe",
+ "emailAddressPlaceholder": "john.doe@company.com",
+ "organizationPlaceholder": "Your organization",
+ "anyAdditionalComments": "Any additional comments?",
+ "fieldRequired": "This field is required.",
+ "invalidEmail": "Please enter a valid email address.",
+ "submitting": "Submitting...",
+ "automaticRedirectInSeconds": "Automatic redirect in {count, plural, one {# second} other {# seconds}}",
+ "errorSubmittingQuiz": "Something went wrong. Please try again.",
"feedTileSummaryPlaceholder": "Optional: Enter a custom summary text to display on feed tiles (if not provided, a summary will be auto-generated from the notebook content)"
}
diff --git a/front_end/messages/es.json b/front_end/messages/es.json
index 372fbcd21b..5c58dba1ca 100644
--- a/front_end/messages/es.json
+++ b/front_end/messages/es.json
@@ -1940,5 +1940,48 @@
"yourOrganization": "Tu organización",
"yourMessageForm": "Tu mensaje",
"readFullReport": "Leer el informe completo",
+ "discoverServicesTitle": "Descubrir Servicios",
+ "discoverServicesSubtitle": "Seleccione su tipo de organización para comenzar",
+ "discoverServicesEnterpriseTitle": "Empresas",
+ "discoverServicesEnterpriseDescription": "Para corporaciones que gestionan riesgos de mercado, cadenas de suministro y estrategia.",
+ "discoverServicesGovernmentTitle": "Gobierno",
+ "discoverServicesGovernmentDescription": "Para responsables políticos que navegan por cambios en IA, defensa, salud y mercados laborales.",
+ "discoverServicesNonProfitTitle": "Sin Fines de Lucro",
+ "discoverServicesNonProfitDescription": "Para fundaciones, ONG y think tanks impulsando impacto social.",
+ "discoverServicesAcademiaTitle": "Academia",
+ "discoverServicesAcademiaDescription": "Para universidades e instituciones que avanzan en el descubrimiento científico.",
+ "workWithMetaculus": "Trabaja con Metaculus",
+ "selectChallengesYouAreCurrentlyFacing": "Selecciona los desafíos que estás enfrentando actualmente",
+ "typeHere": "Escribe aquí…",
+ "continue": "Continuar",
+ "youHaveUnsavedProgress": "Tienes progreso sin guardar.",
+ "stay": "Quedarse",
+ "stepsLeft": "{count, plural, one {# paso restante} other {# pasos restantes}}",
+ "howSoonDoYouNeedForecasts": "¿Cuán pronto necesitas las previsiones?",
+ "soonerThanThreeMonths": "Antes de 3 meses",
+ "laterThanThreeMonths": "Después de 3 meses",
+ "flexibleUnsure": "Flexible / aún no estoy seguro",
+ "whoShouldMakeTheForecasts": "¿Quién debería hacer las previsiones?",
+ "metaculusPros": "Profesionales de Metaculus",
+ "clientExperts": "Tus expertos internos",
+ "notSure": "No estoy seguro",
+ "privacyQuestionTitle": "¿Qué tan confidencial es este trabajo?",
+ "semiConfidentiality": "Algo de confidencialidad",
+ "fullConfidentiality": "Confidencialidad total",
+ "weHaveReceivedYourRequest": "Hemos recibido tu solicitud",
+ "backToServices": "Volver a Servicios",
+ "semiConfidentialityDescription": "Organización oculta pero las preguntas pueden ser públicas",
+ "fullConfidentialityDescription": "Tanto la organización como las preguntas están ocultas",
+ "submitYourAnswersTitle": "Envía tus respuestas y nos pondremos en contacto",
+ "yourNamePlaceholder": "John Doe",
+ "emailAddressPlaceholder": "john.doe@company.com",
+ "organizationPlaceholder": "Nombre de la organización",
+ "anyAdditionalComments": "¿Algún comentario adicional?",
+ "fieldRequired": "Este campo es obligatorio.",
+ "invalidEmail": "Por favor ingrese una dirección de correo electrónico válida.",
+ "submitting": "Enviando...",
+ "ourTeamWillBeInContactSoon": "Nuestro equipo se pondrá en contacto pronto.",
+ "automaticRedirectInSeconds": "Redirección automática en {count, plural, one {# segundo} other {# segundos}}",
+ "errorSubmittingQuiz": "Algo salió mal. Por favor, inténtalo de nuevo.",
"thousandsOfOpenQuestions": "20,000+ preguntas abiertas"
}
diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json
index d6ccef108a..c22a58ed33 100644
--- a/front_end/messages/pt.json
+++ b/front_end/messages/pt.json
@@ -1938,5 +1938,48 @@
"yourOrganization": "Sua organização",
"yourMessageForm": "Sua mensagem",
"readFullReport": "Leia o relatório completo",
+ "discoverServicesTitle": "Descubra Serviços",
+ "discoverServicesSubtitle": "Selecione o tipo de organização para começar",
+ "discoverServicesEnterpriseTitle": "Empresa",
+ "discoverServicesEnterpriseDescription": "Para corporações que gerenciam risco de mercado, cadeias de suprimento e estratégia.",
+ "discoverServicesGovernmentTitle": "Governo",
+ "discoverServicesGovernmentDescription": "Para formuladores de políticas que navegam em mudanças na IA, defesa, saúde e mercados de trabalho.",
+ "discoverServicesNonProfitTitle": "Sem Fins Lucrativos",
+ "discoverServicesNonProfitDescription": "Para fundações, ONGs e think tanks impulsionando impacto social.",
+ "discoverServicesAcademiaTitle": "Academia",
+ "discoverServicesAcademiaDescription": "Para universidades e instituições que promovem a descoberta científica.",
+ "workWithMetaculus": "Trabalhe com Metaculus",
+ "selectChallengesYouAreCurrentlyFacing": "Selecione os desafios que você está enfrentando atualmente",
+ "typeHere": "Digite aqui...",
+ "continue": "Continuar",
+ "youHaveUnsavedProgress": "Você tem progresso não salvo.",
+ "stay": "Ficar",
+ "stepsLeft": "{count, plural, one {# passo restante} other {# passos restantes}}",
+ "howSoonDoYouNeedForecasts": "Com que urgência você precisa das previsões?",
+ "soonerThanThreeMonths": "Mais rápido que 3 meses",
+ "laterThanThreeMonths": "Mais tarde que 3 meses",
+ "flexibleUnsure": "Flexível / ainda não tenho certeza",
+ "whoShouldMakeTheForecasts": "Quem deve fazer as previsões?",
+ "metaculusPros": "Profissionais da Metaculus",
+ "clientExperts": "Seus especialistas internos",
+ "notSure": "Não tenho certeza",
+ "privacyQuestionTitle": "Qual é a confidencialidade deste trabalho?",
+ "semiConfidentiality": "Alguma confidencialidade",
+ "fullConfidentiality": "Confidencialidade total",
+ "weHaveReceivedYourRequest": "Recebemos seu pedido",
+ "backToServices": "Voltar para Serviços",
+ "semiConfidentialityDescription": "Organização oculta, mas questões podem ser públicas",
+ "fullConfidentialityDescription": "Tanto a organização quanto as questões estão ocultas",
+ "submitYourAnswersTitle": "Envie suas respostas e entraremos em contato",
+ "yourNamePlaceholder": "João Silva",
+ "emailAddressPlaceholder": "joao.silva@empresa.com",
+ "organizationPlaceholder": "Nome da organização",
+ "anyAdditionalComments": "Algum comentário adicional?",
+ "fieldRequired": "Este campo é obrigatório.",
+ "invalidEmail": "Por favor, insira um endereço de e-mail válido.",
+ "submitting": "Enviando...",
+ "ourTeamWillBeInContactSoon": "Nossa equipe entrará em contato em breve.",
+ "automaticRedirectInSeconds": "Redirecionamento automático em {count, plural, one {# segundo} other {# segundos}}",
+ "errorSubmittingQuiz": "Algo deu errado. Por favor, tente novamente.",
"thousandsOfOpenQuestions": "20.000+ perguntas abertas"
}
diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json
index 6a35eb5fcc..220c80f771 100644
--- a/front_end/messages/zh-TW.json
+++ b/front_end/messages/zh-TW.json
@@ -1937,5 +1937,48 @@
"yourOrganization": "您的組織",
"yourMessageForm": "您的訊息",
"readFullReport": "閱讀完整報告",
+ "discoverServicesTitle": "發現服務",
+ "discoverServicesSubtitle": "選擇您的組織類型以開始",
+ "discoverServicesEnterpriseTitle": "企業",
+ "discoverServicesEnterpriseDescription": "適用於管理市場風險、供應鏈和戰略的公司。",
+ "discoverServicesGovernmentTitle": "政府",
+ "discoverServicesGovernmentDescription": "適用於應對人工智慧、防禦、健康和勞動市場轉變的政策制定者。",
+ "discoverServicesNonProfitTitle": "非營利組織",
+ "discoverServicesNonProfitDescription": "適用於推動社會影響的基金會、非政府組織和智庫。",
+ "discoverServicesAcademiaTitle": "學術界",
+ "discoverServicesAcademiaDescription": "適用於促進科學發現的大學和研究機構。",
+ "workWithMetaculus": "與 Metaculus 合作",
+ "selectChallengesYouAreCurrentlyFacing": "選擇您當前面臨的挑戰",
+ "typeHere": "在此輸入...",
+ "continue": "繼續",
+ "youHaveUnsavedProgress": "您有未儲存的進度。",
+ "stay": "留下",
+ "stepsLeft": "{count, plural, one {還剩 # 步} other {還剩 # 步}}",
+ "howSoonDoYouNeedForecasts": "您多快需要預測?",
+ "soonerThanThreeMonths": "少於 3 個月",
+ "laterThanThreeMonths": "多於 3 個月",
+ "flexibleUnsure": "靈活 / 尚不確定",
+ "whoShouldMakeTheForecasts": "誰應該製作預測?",
+ "metaculusPros": "Metaculus 專家",
+ "clientExperts": "您的內部專家",
+ "notSure": "不確定",
+ "privacyQuestionTitle": "此項工作具多大保密性?",
+ "semiConfidentiality": "某些保密性",
+ "fullConfidentiality": "完全保密",
+ "weHaveReceivedYourRequest": "我們已收到您的請求",
+ "backToServices": "返回服務",
+ "semiConfidentialityDescription": "組織隱藏但問題可以公開",
+ "fullConfidentialityDescription": "組織和問題均隱藏",
+ "submitYourAnswersTitle": "提交您的答案,我們將與您聯繫",
+ "yourNamePlaceholder": "約翰·多伊",
+ "emailAddressPlaceholder": "john.doe@company.com",
+ "organizationPlaceholder": "組織名稱",
+ "anyAdditionalComments": "有任何其他評論?",
+ "fieldRequired": "這是必填欄位。",
+ "invalidEmail": "請輸入有效的電子郵件地址。",
+ "submitting": "提交中...",
+ "ourTeamWillBeInContactSoon": "我們的團隊將很快與您聯繫。",
+ "automaticRedirectInSeconds": "{count} 秒後自動重定向",
+ "errorSubmittingQuiz": "出了點問題,請重試。",
"thousandsOfOpenQuestions": "20,000+ 開放問題"
}
diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json
index 4b2c17d4ea..5f74920b0b 100644
--- a/front_end/messages/zh.json
+++ b/front_end/messages/zh.json
@@ -1942,5 +1942,48 @@
"yourOrganization": "您的组织",
"yourMessageForm": "您的信息",
"readFullReport": "阅读完整报告",
+ "discoverServicesTitle": "发现服务",
+ "discoverServicesSubtitle": "选择您的组织类型以开始",
+ "discoverServicesEnterpriseTitle": "企业",
+ "discoverServicesEnterpriseDescription": "适用于管理市场风险、供应链和战略的公司。",
+ "discoverServicesGovernmentTitle": "政府",
+ "discoverServicesGovernmentDescription": "适用于在人工智能、防务、健康和劳动力市场中导航政策变化的决策者。",
+ "discoverServicesNonProfitTitle": "非营利组织",
+ "discoverServicesNonProfitDescription": "适用于推动社会影响的基金会、非政府组织和智库。",
+ "discoverServicesAcademiaTitle": "学术机构",
+ "discoverServicesAcademiaDescription": "适用于推动科学发现的大学和机构。",
+ "workWithMetaculus": "与 Metaculus 合作",
+ "selectChallengesYouAreCurrentlyFacing": "选择您目前面临的挑战",
+ "typeHere": "在这里输入……",
+ "continue": "继续",
+ "youHaveUnsavedProgress": "您有未保存的进度。",
+ "stay": "停留",
+ "stepsLeft": "{count, plural, one {剩下 # 步} other {剩下 # 步}}",
+ "howSoonDoYouNeedForecasts": "您需要多快获得预测?",
+ "soonerThanThreeMonths": "少于 3 个月",
+ "laterThanThreeMonths": "超过 3 个月",
+ "flexibleUnsure": "灵活 / 尚不确定",
+ "whoShouldMakeTheForecasts": "谁应该进行预测?",
+ "metaculusPros": "Metaculus 专家",
+ "clientExperts": "您的内部专家",
+ "notSure": "不确定",
+ "privacyQuestionTitle": "这项工作有多保密?",
+ "semiConfidentiality": "一定程度的保密",
+ "fullConfidentiality": "完全保密",
+ "weHaveReceivedYourRequest": "我们已收到您的请求",
+ "backToServices": "返回服务",
+ "semiConfidentialityDescription": "组织隐藏,但问题可以公开",
+ "fullConfidentialityDescription": "组织和问题均隐藏",
+ "submitYourAnswersTitle": "提交您的答案,我们将与您联系",
+ "yourNamePlaceholder": "约翰·多伊",
+ "emailAddressPlaceholder": "john.doe@company.com",
+ "organizationPlaceholder": "组织名称",
+ "anyAdditionalComments": "还有其他任何评论吗?",
+ "fieldRequired": "此字段是必填项。",
+ "invalidEmail": "请输入有效的电子邮件地址。",
+ "submitting": "正在提交...",
+ "ourTeamWillBeInContactSoon": "我们的团队将尽快与您联系。",
+ "automaticRedirectInSeconds": "{count}秒后自动重定向",
+ "errorSubmittingQuiz": "出了点问题,请重试。",
"thousandsOfOpenQuestions": "20,000+ 开放问题"
}
diff --git a/front_end/src/app/(main)/services/components/case_studies/constants.tsx b/front_end/src/app/(main)/services/components/case_studies/constants.tsx
index 9e0a61f985..56a6599689 100644
--- a/front_end/src/app/(main)/services/components/case_studies/constants.tsx
+++ b/front_end/src/app/(main)/services/components/case_studies/constants.tsx
@@ -95,7 +95,7 @@ export const CASE_STUDIES: TCaseStudyCard[] = [
],
},
aboutInitiative:
- "Developed in collaboration with Our World in Data (OWID), this project deployed Metaculus Pro Forecasters to predict key measures of human progress over various time horizons. Thirty OWID-tracked time-series metrics were selected to collectively illuminate what the future might hold according to Metaculus\u2019s most accurate forecasters.",
+ "Developed in collaboration with Our World in Data (OWID), this project deployed Metaculus Pro Forecasters to predict key measures of human progress over various time horizons. Thirty OWID-tracked time-series metrics were selected to collectively illuminate what the future might hold according to Metaculus's most accurate forecasters.",
report: {
previewImageSrc: OwidPreview,
previewImageAlt: "OWID report preview",
diff --git a/front_end/src/app/(main)/services/components/discover_services/constants.ts b/front_end/src/app/(main)/services/components/discover_services/constants.ts
new file mode 100644
index 0000000000..fecd990968
--- /dev/null
+++ b/front_end/src/app/(main)/services/components/discover_services/constants.ts
@@ -0,0 +1,33 @@
+import {
+ faGraduationCap,
+ faIndustry,
+ faLandmark,
+ faPeopleGroup,
+} from "@fortawesome/free-solid-svg-icons";
+
+export const DISCOVER_SERVICES_CARDS = [
+ {
+ type: "enterprise",
+ titleKey: "discoverServicesEnterpriseTitle",
+ descriptionKey: "discoverServicesEnterpriseDescription",
+ icon: faIndustry,
+ },
+ {
+ type: "government",
+ titleKey: "discoverServicesGovernmentTitle",
+ descriptionKey: "discoverServicesGovernmentDescription",
+ icon: faLandmark,
+ },
+ {
+ type: "non-profit",
+ titleKey: "discoverServicesNonProfitTitle",
+ descriptionKey: "discoverServicesNonProfitDescription",
+ icon: faPeopleGroup,
+ },
+ {
+ type: "academia",
+ titleKey: "discoverServicesAcademiaTitle",
+ descriptionKey: "discoverServicesAcademiaDescription",
+ icon: faGraduationCap,
+ },
+] as const;
diff --git a/front_end/src/app/(main)/services/components/discover_services/discover_services_block.tsx b/front_end/src/app/(main)/services/components/discover_services/discover_services_block.tsx
new file mode 100644
index 0000000000..3027cce5be
--- /dev/null
+++ b/front_end/src/app/(main)/services/components/discover_services/discover_services_block.tsx
@@ -0,0 +1,50 @@
+import { faArrowRight } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+
+import cn from "@/utils/core/cn";
+
+import { DISCOVER_SERVICES_CARDS } from "./constants";
+import SectionHeading from "../section_heading";
+
+const DiscoverServicesBlock: React.FC = () => {
+ const t = useTranslations();
+
+ return (
+ <>
+
+
+
+ {DISCOVER_SERVICES_CARDS.map((card) => (
+
+
+
+
+
+
+
+ {t(card.titleKey)}
+
+
+ {t(card.descriptionKey)}
+
+
+ ))}
+
+ >
+ );
+};
+
+export default DiscoverServicesBlock;
diff --git a/front_end/src/app/(main)/services/components/templates/services_page_template.tsx b/front_end/src/app/(main)/services/components/templates/services_page_template.tsx
index 754d4ea642..c8d34d4a96 100644
--- a/front_end/src/app/(main)/services/components/templates/services_page_template.tsx
+++ b/front_end/src/app/(main)/services/components/templates/services_page_template.tsx
@@ -16,6 +16,7 @@ import Button from "../button";
import CaseStudyCard from "../case_studies/case_study_card";
import { TCaseStudyCard } from "../case_studies/types";
import ContactSection from "../contact_section/contact_section";
+import DiscoverServicesBlock from "../discover_services/discover_services_block";
import HeadingBlock from "../heading_block";
import PartnersCarousel from "../partners_carousel";
import SectionHeading from "../section_heading";
@@ -120,6 +121,8 @@ const ServicesPageTemplate: React.FC = async ({
/>
+
+
= ({ isOpen, onClose, tournamentSlug }) => {
const { postsLeft, flowType } = usePredictionFlow();
return (
-
-
-
- {t("exitPredictionFlow")}
-
-
- {t.rich(
- isNil(flowType)
- ? "thereAreQuestionsYouHaveNotPredicted"
- : "thereAreQuestionsThatRequireAttention",
- {
- count: postsLeft,
- bold: (chunks) => {chunks},
- }
- )}
-
-
- {t("youCanComeBackAnytime")}
-
-
-
-
-
-
-
+ title={t("exitPredictionFlow")}
+ description={t.rich(
+ isNil(flowType)
+ ? "thereAreQuestionsYouHaveNotPredicted"
+ : "thereAreQuestionsThatRequireAttention",
+ {
+ count: postsLeft,
+ bold: (chunks) => {chunks},
+ }
+ )}
+ note={t("youCanComeBackAnytime")}
+ secondaryAction={{ label: t("keepPredicting") }}
+ primaryAction={{
+ label: t("exit"),
+ onClick: () => router.push(`/tournament/${tournamentSlug}`),
+ }}
+ />
);
};
diff --git a/front_end/src/app/(prediction-flow)/components/header.tsx b/front_end/src/app/(prediction-flow)/components/header.tsx
index b1505bff38..7224b8455e 100644
--- a/front_end/src/app/(prediction-flow)/components/header.tsx
+++ b/front_end/src/app/(prediction-flow)/components/header.tsx
@@ -5,8 +5,15 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
-import { FC, useState } from "react";
+import React, { FC } from "react";
+import {
+ FlowHeaderActions,
+ FlowHeaderBrand,
+ FlowHeaderRoot,
+ FlowHeaderTitle,
+} from "@/components/flow/flow_header";
+import { useExitGuard } from "@/components/flow/use_exit_guard";
import Button from "@/components/ui/button";
import cn from "@/utils/core/cn";
@@ -25,11 +32,16 @@ const PredictionFlowHeader: FC = ({
const router = useRouter();
const t = useTranslations();
const { postsLeft } = usePredictionFlow();
- const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const { isExitModalOpen, requestExit, closeExitModal } = useExitGuard({
+ canExitImmediately: postsLeft === 0,
+ onExit: () => router.push(`/tournament/${tournamentSlug}`),
+ });
+
return (
<>
-
-
+
+
= ({
M
-
-
- {tournamentName}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
setIsModalOpen(false)}
+ isOpen={isExitModalOpen}
+ onClose={closeExitModal}
tournamentSlug={tournamentSlug}
/>
>
diff --git a/front_end/src/app/(prediction-flow)/components/progress_section.tsx b/front_end/src/app/(prediction-flow)/components/progress_section.tsx
index 910383537f..031c6e7661 100644
--- a/front_end/src/app/(prediction-flow)/components/progress_section.tsx
+++ b/front_end/src/app/(prediction-flow)/components/progress_section.tsx
@@ -1,18 +1,23 @@
"use client";
-import { faBars, faXmark } from "@fortawesome/free-solid-svg-icons";
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { isNil } from "lodash";
import { useTranslations } from "next-intl";
-import { FC, useMemo } from "react";
+import React, { FC, useMemo } from "react";
-import PostStepButton from "@/app/(prediction-flow)/components/post_step_button";
-import { usePredictionFlow } from "@/app/(prediction-flow)/components/prediction_flow_provider";
-import Button from "@/components/ui/button";
-import cn from "@/utils/core/cn";
+import {
+ FlowStepperRoot,
+ FlowStepperHeader,
+ FlowStepperSegments,
+ FlowStepperNav,
+ FlowStepperNavPrev,
+ FlowStepperNavNext,
+ FlowStepperMenu,
+ FlowStep,
+} from "@/components/flow/flow_stepper";
import { isPostOpenQuestionPredicted } from "@/utils/forecasts/helpers";
import PredictionFlowMenu from "./prediction_flow_menu";
+import { usePredictionFlow } from "./prediction_flow_provider";
const ProgressSection: FC = () => {
const t = useTranslations();
@@ -26,84 +31,74 @@ const ProgressSection: FC = () => {
changeActivePost,
} = usePredictionFlow();
+ const steps: FlowStep[] = useMemo(() => {
+ return posts.map((post) => ({
+ id: post.id,
+ isDone: isNil(flowType) ? isPostOpenQuestionPredicted(post) : post.isDone,
+ }));
+ }, [posts, flowType]);
+
const currentIndex = useMemo(
() => posts.findIndex((post) => post.id === currentPostId),
[posts, currentPostId]
);
- const handleClick = (isPrevious: boolean) => {
- if (isPrevious ? currentIndex > 0 : currentIndex <= posts.length - 1) {
- changeActivePost(
- posts[isPrevious ? currentIndex - 1 : currentIndex + 1]?.id ?? null
- );
- }
- };
+ const headerLabel = isNil(currentPostId)
+ ? t("questionsTotal", { count: posts.length })
+ : t("questionsLeft", { count: postsLeft });
+
+ const nextLabel = t(
+ isNil(flowType) && posts[currentIndex]
+ ? isPostOpenQuestionPredicted(posts[currentIndex])
+ ? "nextQuestion"
+ : "skipQuestions"
+ : posts[currentIndex]?.isDone
+ ? "nextQuestion"
+ : "skipQuestions"
+ );
+
+ const showNav = currentPostId !== null && !isMenuOpen;
+ const prevDisabled = currentIndex <= 0;
+ const nextDisabled = currentIndex > posts.length - 1 || currentIndex < 0;
return (
-
-
-
- {isNil(currentPostId)
- ? t("questionsTotal", { count: posts.length })
- : t("questionsLeft", { count: postsLeft })}
-
-
-
- {/* Questions bars */}
-
- {posts.map((post, index) => (
-
- ))}
-
- {/* Navigation buttons */}
- {!isNil(currentPostId) && !isMenuOpen && (
-
-
-
-
+ {nextLabel}
+
+
)}
- {isMenuOpen &&
}
-
+
+
+
+
+
);
};
diff --git a/front_end/src/app/(services-quiz)/append_services_quiz_row.ts b/front_end/src/app/(services-quiz)/append_services_quiz_row.ts
new file mode 100644
index 0000000000..cacc40e09e
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/append_services_quiz_row.ts
@@ -0,0 +1,122 @@
+"use server";
+
+import {
+ appendSheetRow,
+ getSheetFirstRow,
+ setSheetRow,
+} from "@/services/google_spreadsheets";
+
+import type { ServicesQuizSubmitPayload } from "./helpers";
+
+function mustEnv(name: string) {
+ const v = process.env[name];
+ if (!v) throw new Error(`Missing env var: ${name}`);
+ return v;
+}
+
+function normalizeWhoForecasts(
+ v: ServicesQuizSubmitPayload["whoForecasts"] | null | undefined
+) {
+ if (!v) return "";
+ if (v.mode === "not_sure") return "not_sure";
+ return v.selections.join(",");
+}
+
+const HEADER_ROW = [
+ "created_at",
+ "category",
+ "challenges",
+ "notes",
+ "timing",
+ "who_forecasts",
+ "privacy",
+ "contact_name",
+ "contact_email",
+ "contact_organization",
+ "contact_comments",
+] as const;
+
+async function ensureHeaderRow(
+ spreadsheetId: string,
+ credentialsBase64: string,
+ sheetName: string
+) {
+ const firstRow = await getSheetFirstRow(
+ spreadsheetId,
+ credentialsBase64,
+ sheetName
+ );
+
+ const isEmpty =
+ firstRow.length === 0 ||
+ firstRow.every((cell) => String(cell).trim() === "");
+
+ if (!isEmpty) return;
+
+ await setSheetRow(spreadsheetId, credentialsBase64, {
+ sheetName,
+ row: [...HEADER_ROW],
+ rowIndex: 1,
+ valueInputOption: "RAW",
+ });
+}
+
+export async function appendServicesQuizRow(
+ payload: ServicesQuizSubmitPayload
+) {
+ const spreadsheetId = mustEnv("SERVICES_QUIZ_GOOGLE_SHEETS_SPREADSHEET_ID");
+ const credentialsBase64 = mustEnv("GOOGLE_CREDEBTIALS_FAB_SHEET_B64");
+ const sheetName = "Sheet1";
+
+ await ensureHeaderRow(spreadsheetId, credentialsBase64, sheetName);
+
+ const row = [
+ new Date().toISOString(),
+ payload.category ?? "",
+ payload.challenges.join(" | "),
+ payload.notes ?? "",
+ payload.timing ?? "",
+ normalizeWhoForecasts(payload.whoForecasts),
+ payload.privacy ?? "",
+ payload.contact.name ?? "",
+ payload.contact.email ?? "",
+ payload.contact.organization ?? "",
+ payload.contact.comments ?? "",
+ ];
+
+ await appendSheetRow(spreadsheetId, credentialsBase64, {
+ sheetName,
+ row,
+ valueInputOption: "USER_ENTERED",
+ });
+
+ const zapierWebhookUrl = process.env.SERVICES_QUIZ_ZAPIER_WEBHOOK_URL;
+ if (zapierWebhookUrl) {
+ try {
+ const response = await fetch(zapierWebhookUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ created_at: row[0],
+ category: payload.category,
+ challenges: payload.challenges.join(" | "),
+ notes: payload.notes,
+ timing: payload.timing,
+ who_forecasts: normalizeWhoForecasts(payload.whoForecasts),
+ privacy: payload.privacy,
+ contact_name: payload.contact.name,
+ contact_email: payload.contact.email,
+ contact_organization: payload.contact.organization,
+ contact_comments: payload.contact.comments,
+ }),
+ });
+ if (!response.ok) {
+ console.error(
+ `Zapier webhook failed: ${response.status} ${response.statusText}`
+ );
+ }
+ } catch (error) {
+ console.error("Zapier webhook error:", error);
+ }
+ }
+}
diff --git a/front_end/src/app/(services-quiz)/components/fields/services_quiz_notes_input.tsx b/front_end/src/app/(services-quiz)/components/fields/services_quiz_notes_input.tsx
new file mode 100644
index 0000000000..b742438bc6
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/fields/services_quiz_notes_input.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import { faPen, faXmark } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useTranslations } from "next-intl";
+import React, { FC, useCallback, useLayoutEffect, useRef } from "react";
+
+import cn from "@/utils/core/cn";
+
+type Props = {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+ minHeightClassName?: string;
+ maxHeightPx?: number;
+};
+
+const ServicesQuizNotesInput: FC = ({
+ value,
+ onChange,
+ placeholder,
+ disabled,
+ className,
+ minHeightClassName = "min-h-[56px]",
+ maxHeightPx,
+}) => {
+ const t = useTranslations();
+ const hasValue = value.trim().length > 0;
+ const ref = useRef(null);
+
+ const resize = useCallback(() => {
+ const el = ref.current;
+ if (!el) return;
+ el.style.height = "0px";
+ const next = el.scrollHeight;
+ const capped =
+ typeof maxHeightPx === "number" ? Math.min(next, maxHeightPx) : next;
+
+ el.style.height = `${capped}px`;
+ el.style.overflowY =
+ typeof maxHeightPx === "number" && next > maxHeightPx ? "auto" : "hidden";
+ }, [maxHeightPx]);
+
+ useLayoutEffect(() => {
+ resize();
+ }, [resize, value]);
+
+ return (
+
+ );
+};
+
+export default ServicesQuizNotesInput;
diff --git a/front_end/src/app/(services-quiz)/components/fields/services_quiz_radio_card.tsx b/front_end/src/app/(services-quiz)/components/fields/services_quiz_radio_card.tsx
new file mode 100644
index 0000000000..49e17c92da
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/fields/services_quiz_radio_card.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { faCircle as faCircleRegular } from "@fortawesome/free-regular-svg-icons";
+import { faCircleCheck } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import React, { FC, ReactNode } from "react";
+
+import cn from "@/utils/core/cn";
+
+type Props = {
+ title: ReactNode;
+ description?: ReactNode;
+ isSelected: boolean;
+ onSelect: () => void;
+ onDeselect?: () => void;
+ disabled?: boolean;
+ className?: string;
+};
+
+const ServicesQuizRadioCard: FC = ({
+ title,
+ description,
+ isSelected,
+ onSelect,
+ onDeselect,
+ disabled,
+ className,
+}) => {
+ const handleClick = () => {
+ if (disabled) return;
+
+ if (isSelected) {
+ onDeselect?.();
+ return;
+ }
+
+ onSelect();
+ };
+
+ return (
+
+ );
+};
+
+export default ServicesQuizRadioCard;
diff --git a/front_end/src/app/(services-quiz)/components/fields/services_quiz_text_field.tsx b/front_end/src/app/(services-quiz)/components/fields/services_quiz_text_field.tsx
new file mode 100644
index 0000000000..09d95723e8
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/fields/services_quiz_text_field.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import React, { FC } from "react";
+
+import cn from "@/utils/core/cn";
+
+type Props = {
+ label: React.ReactNode;
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+ type?: React.InputHTMLAttributes["type"];
+ disabled?: boolean;
+ className?: string;
+};
+
+const ServicesQuizTextField: FC = ({
+ label,
+ value,
+ onChange,
+ placeholder,
+ type = "text",
+ disabled,
+ className,
+}) => {
+ return (
+
+ );
+};
+
+export default ServicesQuizTextField;
diff --git a/front_end/src/app/(services-quiz)/components/fields/services_quiz_toggle_chip.tsx b/front_end/src/app/(services-quiz)/components/fields/services_quiz_toggle_chip.tsx
new file mode 100644
index 0000000000..1eb37ae478
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/fields/services_quiz_toggle_chip.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import React, { FC, ReactNode } from "react";
+
+import cn from "@/utils/core/cn";
+
+type Props = {
+ label: ReactNode;
+ isSelected: boolean;
+ onToggle: () => void;
+ disabled?: boolean;
+ className?: string;
+};
+
+const ServicesQuizToggleChip: FC = ({
+ label,
+ isSelected,
+ onToggle,
+ disabled,
+ className,
+}) => {
+ return (
+
+ );
+};
+
+export default ServicesQuizToggleChip;
diff --git a/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_answers_provider.tsx b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_answers_provider.tsx
new file mode 100644
index 0000000000..ac20ff0576
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_answers_provider.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import React, {
+ createContext,
+ FC,
+ PropsWithChildren,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+
+import { ServicesQuizCategory } from "../../constants";
+
+export type ServicesQuizStepId = 1 | 2 | 3 | 4 | 5 | 6;
+
+export type ServicesQuizWhoForecasts =
+ | { mode: "not_sure" }
+ | { mode: "selected"; selections: Array<"pros" | "public" | "experts"> };
+
+export type ServicesQuizAnswersState = {
+ category: ServicesQuizCategory | null;
+ selectedChallenges: string[];
+ notes: string;
+ timing: string | null;
+ whoForecasts: ServicesQuizWhoForecasts | null;
+ privacy: string | null;
+ contactName: string;
+ contactEmail: string;
+ contactOrg: string;
+ contactComments: string;
+};
+
+type AnswersApi = {
+ state: ServicesQuizAnswersState;
+
+ toggleChallenge: (ch: string) => void;
+ setNotes: (v: string) => void;
+
+ setTiming: (v: string | null) => void;
+
+ toggleWhoForecastsSelection: (v: "pros" | "public" | "experts") => void;
+ setWhoForecastsNotSure: () => void;
+ clearWhoForecasts: () => void;
+
+ setPrivacy: (v: string | null) => void;
+
+ setContactName: (v: string) => void;
+ setContactEmail: (v: string) => void;
+ setContactOrg: (v: string) => void;
+ setContactComments: (v: string) => void;
+};
+
+const Ctx = createContext(null);
+
+export const useServicesQuizAnswers = () => {
+ const ctx = useContext(Ctx);
+ if (!ctx)
+ throw new Error("useServicesQuizAnswers must be used within provider");
+ return ctx;
+};
+
+type Props = PropsWithChildren<{
+ category: ServicesQuizCategory | null;
+}>;
+
+export const ServicesQuizAnswersProvider: FC = ({
+ category,
+ children,
+}) => {
+ const [selectedChallenges, setSelectedChallenges] = useState([]);
+ const [notes, setNotes] = useState("");
+
+ const [timing, setTiming] = useState(null);
+ const [whoForecasts, setWhoForecasts] =
+ useState(null);
+
+ const [privacy, setPrivacy] = useState(null);
+
+ const [contactName, setContactName] = useState("");
+ const [contactEmail, setContactEmail] = useState("");
+ const [contactOrg, setContactOrg] = useState("");
+ const [contactComments, setContactComments] = useState("");
+
+ const value = useMemo(
+ () => ({
+ state: {
+ category,
+ selectedChallenges,
+ notes,
+ timing,
+ whoForecasts,
+ privacy,
+ contactName,
+ contactEmail,
+ contactOrg,
+ contactComments,
+ },
+ toggleChallenge: (ch) =>
+ setSelectedChallenges((prev) =>
+ prev.includes(ch) ? prev.filter((x) => x !== ch) : [...prev, ch]
+ ),
+ setNotes,
+ setTiming,
+ toggleWhoForecastsSelection: (v) =>
+ setWhoForecasts((prev) => {
+ if (!prev || prev.mode === "not_sure") {
+ return { mode: "selected", selections: [v] };
+ }
+
+ const exists = prev.selections.includes(v);
+ const nextSelections = exists
+ ? prev.selections.filter((x) => x !== v)
+ : [...prev.selections, v];
+
+ if (nextSelections.length === 0) return null;
+
+ return { mode: "selected", selections: nextSelections };
+ }),
+ setWhoForecastsNotSure: () => setWhoForecasts({ mode: "not_sure" }),
+ clearWhoForecasts: () => setWhoForecasts(null),
+
+ setPrivacy,
+ setContactName,
+ setContactEmail,
+ setContactOrg,
+ setContactComments,
+ }),
+ [
+ category,
+ selectedChallenges,
+ notes,
+ timing,
+ whoForecasts,
+ privacy,
+ contactName,
+ contactEmail,
+ contactOrg,
+ contactComments,
+ ]
+ );
+
+ return {children};
+};
diff --git a/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_completion_provider.tsx b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_completion_provider.tsx
new file mode 100644
index 0000000000..d57c774f16
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_completion_provider.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import React, {
+ createContext,
+ FC,
+ PropsWithChildren,
+ useContext,
+ useMemo,
+} from "react";
+import { z } from "zod";
+
+import {
+ useServicesQuizAnswers,
+ ServicesQuizStepId,
+} from "./services_quiz_answers_provider";
+
+type CompletionApi = {
+ isStepDone: (step: ServicesQuizStepId) => boolean;
+ isNextDisabled: (step: ServicesQuizStepId) => boolean;
+};
+
+const Ctx = createContext(null);
+
+export const useServicesQuizCompletion = () => {
+ const ctx = useContext(Ctx);
+ if (!ctx)
+ throw new Error("useServicesQuizCompletion must be used within provider");
+ return ctx;
+};
+
+export const contactSchema = z.object({
+ contactName: z.string().trim().min(1, { message: "fieldRequired" }),
+ contactEmail: z
+ .string()
+ .trim()
+ .min(1, { message: "fieldRequired" })
+ .email({ message: "invalidEmail" }),
+ contactOrg: z.string().trim(),
+ contactComments: z.string().trim(),
+});
+
+export const ServicesQuizCompletionProvider: FC = ({
+ children,
+}) => {
+ const { state } = useServicesQuizAnswers();
+
+ const api = useMemo(() => {
+ const step1Done =
+ !!state.category &&
+ (state.selectedChallenges.length > 0 || state.notes.trim().length > 0);
+
+ const step2Done = !!state.timing;
+ const step3Done = !!state.whoForecasts;
+ const step4Done = !!state.privacy;
+
+ const step5Done = contactSchema.safeParse({
+ contactName: state.contactName,
+ contactEmail: state.contactEmail,
+ contactOrg: state.contactOrg,
+ contactComments: state.contactComments,
+ }).success;
+
+ const isStepDone = (s: ServicesQuizStepId) => {
+ if (s === 1) return step1Done;
+ if (s === 2) return step2Done;
+ if (s === 3) return step3Done;
+ if (s === 4) return step4Done;
+ if (s === 5) return step5Done;
+ if (s === 6) return false;
+ return false;
+ };
+
+ const isNextDisabled = (s: ServicesQuizStepId) => {
+ if (s === 1) return false;
+ if (s === 2) return false;
+ if (s === 3) return false;
+ if (s === 4) return false;
+ if (s === 5) return !step5Done;
+ return true;
+ };
+
+ return { isStepDone, isNextDisabled };
+ }, [state]);
+
+ return {children};
+};
diff --git a/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_exit_guard_provider.tsx b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_exit_guard_provider.tsx
new file mode 100644
index 0000000000..d3938c5d5b
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_exit_guard_provider.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import React, {
+ createContext,
+ FC,
+ PropsWithChildren,
+ useCallback,
+ useContext,
+ useMemo,
+} from "react";
+
+import { useExitGuard } from "@/components/flow/use_exit_guard";
+
+import { useServicesQuizProgress } from "./services_quiz_progress_provider";
+
+type ExitApi = {
+ isExitModalOpen: boolean;
+ requestExit: () => void;
+ closeExitModal: () => void;
+ exitNow: () => void;
+};
+
+const Ctx = createContext(null);
+
+export const useServicesQuizExitGuard = () => {
+ const ctx = useContext(Ctx);
+ if (!ctx) {
+ throw new Error("useServicesQuizExitGuard must be used within provider");
+ }
+ return ctx;
+};
+
+export const ServicesQuizExitGuardProvider: FC<
+ PropsWithChildren<{ exitTo?: string }>
+> = ({ exitTo = "/services", children }) => {
+ const router = useRouter();
+ const { hasProgress } = useServicesQuizProgress();
+
+ const exitNow = useCallback(() => {
+ router.push(exitTo);
+ }, [router, exitTo]);
+
+ const { isExitModalOpen, requestExit, closeExitModal } = useExitGuard({
+ canExitImmediately: !hasProgress,
+ onExit: exitNow,
+ });
+
+ const value = useMemo(
+ () => ({ isExitModalOpen, requestExit, closeExitModal, exitNow }),
+ [isExitModalOpen, requestExit, closeExitModal, exitNow]
+ );
+
+ return {children};
+};
diff --git a/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_flow_provider.tsx b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_flow_provider.tsx
new file mode 100644
index 0000000000..7a32a586b6
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_flow_provider.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import React, {
+ createContext,
+ FC,
+ PropsWithChildren,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from "react";
+
+import { FlowStep } from "@/components/flow/flow_stepper";
+
+import {
+ ServicesQuizStepId,
+ useServicesQuizAnswers,
+} from "./services_quiz_answers_provider";
+import { useServicesQuizCompletion } from "./services_quiz_completion_provider";
+import {
+ aggregateServicesQuizAnswers,
+ ServicesQuizSubmitPayload,
+} from "../../helpers";
+
+const TOTAL_STEPS: ServicesQuizStepId[] = [1, 2, 3, 4, 5, 6];
+
+type FlowApi = {
+ step: ServicesQuizStepId;
+ steps: FlowStep[];
+ stepsLeft: number;
+
+ canGoPrev: boolean;
+ canGoNext: boolean;
+ nextDisabled: boolean;
+
+ isSubmitting: boolean;
+ submitError: Error | null;
+
+ goPrev: () => void;
+ goNext: () => Promise;
+ selectStep: (id: number | string) => void;
+
+ setStep: (s: ServicesQuizStepId) => void;
+};
+
+const Ctx = createContext(null);
+
+export const useServicesQuizFlow = () => {
+ const ctx = useContext(Ctx);
+ if (!ctx) throw new Error("useServicesQuizFlow must be used within provider");
+ return ctx;
+};
+
+export const ServicesQuizFlowProvider: FC<
+ PropsWithChildren<{
+ onSubmit?: (payload: ServicesQuizSubmitPayload) => Promise | void;
+ }>
+> = ({ onSubmit, children }) => {
+ const { state } = useServicesQuizAnswers();
+ const { isStepDone, isNextDisabled } = useServicesQuizCompletion();
+
+ const [step, setStep] = useState(1);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+
+ const steps = useMemo(
+ () =>
+ TOTAL_STEPS.map((id) => ({
+ id,
+ isDone: isStepDone(id) || (id === 6 && step === 6),
+ })),
+ [isStepDone, step]
+ );
+
+ const stepsLeft = Math.max(0, TOTAL_STEPS.length - step);
+ const canGoPrev = step > 1;
+ const canGoNext = step < 6;
+ const nextDisabled = isNextDisabled(step);
+
+ const goPrev = useCallback(() => {
+ if (!canGoPrev || isSubmitting) return;
+ setStep((s) => (s - 1) as ServicesQuizStepId);
+ }, [canGoPrev, isSubmitting]);
+
+ const goNext = useCallback(async () => {
+ if (!canGoNext || nextDisabled || isSubmitting) return;
+
+ if (step === 5) {
+ setIsSubmitting(true);
+ try {
+ const payload = aggregateServicesQuizAnswers(state);
+ await onSubmit?.(payload);
+ setSubmitError(null);
+ setStep(6);
+ } catch (error) {
+ setSubmitError(
+ error instanceof Error ? error : new Error("Submission failed")
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ return;
+ }
+
+ setStep((s) => (s + 1) as ServicesQuizStepId);
+ }, [canGoNext, nextDisabled, isSubmitting, step, onSubmit, state]);
+
+ const selectStep = useCallback(
+ (id: number | string) => {
+ if (isSubmitting) return;
+ const next = id as ServicesQuizStepId;
+ if (!TOTAL_STEPS.includes(next)) return;
+ setStep(next);
+ },
+ [isSubmitting]
+ );
+
+ const value = useMemo(
+ () => ({
+ step,
+ setStep,
+ steps,
+ stepsLeft,
+ canGoPrev,
+ canGoNext,
+ nextDisabled,
+ isSubmitting,
+ submitError,
+ goPrev,
+ goNext,
+ selectStep,
+ }),
+ [
+ step,
+ steps,
+ stepsLeft,
+ canGoPrev,
+ canGoNext,
+ nextDisabled,
+ isSubmitting,
+ submitError,
+ goPrev,
+ goNext,
+ selectStep,
+ ]
+ );
+
+ return {children};
+};
diff --git a/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_progress_provider.tsx b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_progress_provider.tsx
new file mode 100644
index 0000000000..35896ec40e
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_progress_provider.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import React, {
+ createContext,
+ FC,
+ PropsWithChildren,
+ useContext,
+ useMemo,
+} from "react";
+
+import { useServicesQuizAnswers } from "./services_quiz_answers_provider";
+import { useServicesQuizFlow } from "./services_quiz_flow_provider";
+
+type ProgressApi = { hasProgress: boolean };
+
+const Ctx = createContext(null);
+
+export const useServicesQuizProgress = () => {
+ const ctx = useContext(Ctx);
+ if (!ctx)
+ throw new Error("useServicesQuizProgress must be used within provider");
+ return ctx;
+};
+
+export const ServicesQuizProgressProvider: FC = ({
+ children,
+}) => {
+ const { state } = useServicesQuizAnswers();
+ const { step } = useServicesQuizFlow();
+
+ const hasProgress = useMemo(() => {
+ return (
+ step > 1 ||
+ state.selectedChallenges.length > 0 ||
+ state.notes.trim().length > 0 ||
+ !!state.timing ||
+ !!state.whoForecasts ||
+ !!state.privacy ||
+ state.contactName.trim().length > 0 ||
+ state.contactEmail.trim().length > 0
+ );
+ }, [step, state]);
+
+ return {children};
+};
diff --git a/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_root_provider.tsx b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_root_provider.tsx
new file mode 100644
index 0000000000..e3cd3b0ace
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/quiz_state/services_quiz_root_provider.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import React, { FC, PropsWithChildren } from "react";
+
+import { ServicesQuizAnswersProvider } from "./services_quiz_answers_provider";
+import { ServicesQuizCompletionProvider } from "./services_quiz_completion_provider";
+import { ServicesQuizExitGuardProvider } from "./services_quiz_exit_guard_provider";
+import { ServicesQuizFlowProvider } from "./services_quiz_flow_provider";
+import { ServicesQuizProgressProvider } from "./services_quiz_progress_provider";
+import { ServicesQuizCategory } from "../../constants";
+import type { ServicesQuizSubmitPayload } from "../../helpers";
+
+type Props = PropsWithChildren<{
+ initialCategory: ServicesQuizCategory | null;
+ exitTo?: string;
+ onSubmit?: (payload: ServicesQuizSubmitPayload) => Promise | void;
+}>;
+
+export const ServicesQuizRootProvider: FC = ({
+ initialCategory,
+ exitTo,
+ onSubmit,
+ children,
+}) => {
+ return (
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+};
diff --git a/front_end/src/app/(services-quiz)/components/services_quiz_exit_modal.tsx b/front_end/src/app/(services-quiz)/components/services_quiz_exit_modal.tsx
new file mode 100644
index 0000000000..0e494fa502
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/services_quiz_exit_modal.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import React, { FC } from "react";
+
+import FlowExitConfirmModal from "@/components/flow/flow_exit_confirm_modal";
+
+import { useServicesQuizExitGuard } from "./quiz_state/services_quiz_exit_guard_provider";
+
+const ServicesQuizExitModal: FC = () => {
+ const t = useTranslations();
+ const { isExitModalOpen, closeExitModal, exitNow } =
+ useServicesQuizExitGuard();
+
+ return (
+
+ );
+};
+
+export default ServicesQuizExitModal;
diff --git a/front_end/src/app/(services-quiz)/components/services_quiz_header.tsx b/front_end/src/app/(services-quiz)/components/services_quiz_header.tsx
new file mode 100644
index 0000000000..8d89746fa2
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/services_quiz_header.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { faRightFromBracket } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import React, { FC } from "react";
+
+import {
+ FlowHeaderActions,
+ FlowHeaderBrand,
+ FlowHeaderRoot,
+ FlowHeaderTitle,
+} from "@/components/flow/flow_header";
+import Button from "@/components/ui/button";
+import cn from "@/utils/core/cn";
+
+import { useServicesQuizExitGuard } from "./quiz_state/services_quiz_exit_guard_provider";
+
+const ServicesQuizHeader: FC = () => {
+ const t = useTranslations();
+ const { requestExit } = useServicesQuizExitGuard();
+
+ return (
+
+
+
+
+ M
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ServicesQuizHeader;
diff --git a/front_end/src/app/(services-quiz)/components/services_quiz_screen.tsx b/front_end/src/app/(services-quiz)/components/services_quiz_screen.tsx
new file mode 100644
index 0000000000..b6ca12deb9
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/services_quiz_screen.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { FC, useCallback } from "react";
+
+import { appendServicesQuizRow } from "../append_services_quiz_row";
+import { ServicesQuizCategory } from "../constants";
+import { ServicesQuizStepId } from "./quiz_state/services_quiz_answers_provider";
+import { useServicesQuizFlow } from "./quiz_state/services_quiz_flow_provider";
+import { ServicesQuizRootProvider } from "./quiz_state/services_quiz_root_provider";
+import ServicesQuizExitModal from "./services_quiz_exit_modal";
+import ServicesQuizHeader from "./services_quiz_header";
+import ServicesQuizStepper from "./services_quiz_stepper";
+import { ServicesQuizSubmitPayload } from "../helpers";
+import ServicesQuizFinal from "./steps/services_quiz_final";
+import ServicesQuizStep1 from "./steps/services_quiz_step_1";
+import ServicesQuizStep2 from "./steps/services_quiz_step_2";
+import ServicesQuizStep3 from "./steps/services_quiz_step_3";
+import ServicesQuizStep4 from "./steps/services_quiz_step_4";
+import ServicesQuizStep5 from "./steps/services_quiz_step_5";
+
+type Props = {
+ initialCategory: ServicesQuizCategory | null;
+};
+
+const ServicesQuizScreen: FC = ({ initialCategory }) => {
+ const onSubmit = useCallback(async (payload: ServicesQuizSubmitPayload) => {
+ await appendServicesQuizRow(payload);
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+const STEP_COMPONENTS: Record = {
+ 1: ServicesQuizStep1,
+ 2: ServicesQuizStep2,
+ 3: ServicesQuizStep3,
+ 4: ServicesQuizStep4,
+ 5: ServicesQuizStep5,
+ 6: ServicesQuizFinal,
+};
+
+const ServicesQuizScreenInner: FC = () => {
+ const { step } = useServicesQuizFlow();
+ const ActiveStep =
+ STEP_COMPONENTS[step as ServicesQuizStepId] ?? STEP_COMPONENTS[1];
+
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ServicesQuizScreen;
diff --git a/front_end/src/app/(services-quiz)/components/services_quiz_stepper.tsx b/front_end/src/app/(services-quiz)/components/services_quiz_stepper.tsx
new file mode 100644
index 0000000000..c7aa0abca3
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/services_quiz_stepper.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import React, { FC } from "react";
+
+import {
+ FlowStepperRoot,
+ FlowStepperSegments,
+ FlowStepperNav,
+ FlowStepperNavPrev,
+ FlowStepperNavNext,
+ FlowStepId,
+} from "@/components/flow/flow_stepper";
+import cn from "@/utils/core/cn";
+
+import { useServicesQuizCompletion } from "./quiz_state/services_quiz_completion_provider";
+import { useServicesQuizFlow } from "./quiz_state/services_quiz_flow_provider";
+
+const ServicesQuizStepper: FC<{
+ className?: string;
+ hideOnFinalStep?: boolean;
+}> = ({ className, hideOnFinalStep = true }) => {
+ const t = useTranslations();
+
+ const {
+ step,
+ steps,
+ stepsLeft,
+ canGoPrev,
+ canGoNext,
+ nextDisabled,
+ goPrev,
+ goNext,
+ selectStep,
+ } = useServicesQuizFlow();
+
+ const { isStepDone } = useServicesQuizCompletion();
+
+ const isFinalStep = step === 6;
+ const isSubmitStep = step === 5;
+
+ const handleSelectStep = (id: FlowStepId) => {
+ if (id === 6) return;
+ selectStep(id);
+ };
+
+ if (hideOnFinalStep && isFinalStep) return null;
+
+ const prevLabel = t("previous");
+
+ const stepDone = isStepDone(step);
+ const nextLabel = isFinalStep
+ ? t("next")
+ : stepDone
+ ? t("continue")
+ : t("skipQuestions");
+
+ return (
+
+
{}}
+ onSelectStep={handleSelectStep}
+ >
+
+
+ {t("stepsLeft", { count: stepsLeft })}
+
+
+
+
+
+
+
+ {prevLabel}
+
+
+ {!isSubmitStep ? (
+
+ {nextLabel}
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export default ServicesQuizStepper;
diff --git a/front_end/src/app/(services-quiz)/components/steps/services_quiz_final.tsx b/front_end/src/app/(services-quiz)/components/steps/services_quiz_final.tsx
new file mode 100644
index 0000000000..7658ddd45b
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/steps/services_quiz_final.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { faCheckCircle } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useTranslations } from "next-intl";
+import { useEffect, useRef, useState } from "react";
+
+import Button from "@/components/ui/button";
+import cn from "@/utils/core/cn";
+
+import { useServicesQuizExitGuard } from "../quiz_state/services_quiz_exit_guard_provider";
+
+const AUTO_REDIRECT_SECONDS = 5;
+
+const ServicesQuizFinal: React.FC = () => {
+ const t = useTranslations();
+ const { exitNow } = useServicesQuizExitGuard();
+
+ const [secondsLeft, setSecondsLeft] = useState(AUTO_REDIRECT_SECONDS);
+ const didExitRef = useRef(false);
+
+ useEffect(() => {
+ setSecondsLeft(AUTO_REDIRECT_SECONDS);
+ didExitRef.current = false;
+
+ const tick = setInterval(() => {
+ setSecondsLeft((prev) => {
+ const next = prev - 1;
+
+ if (next <= 0 && !didExitRef.current) {
+ didExitRef.current = true;
+ queueMicrotask(() => exitNow());
+ return 0;
+ }
+
+ return Math.max(0, next);
+ });
+ }, 1000);
+
+ return () => clearInterval(tick);
+ }, [exitNow]);
+
+ return (
+
+
+
+
+
+
+ {t("weHaveReceivedYourRequest")}
+
+
+
+ {t("ourTeamWillBeInContactSoon")}
+
+
+
+
+
+
+ {t("automaticRedirectInSeconds", { count: secondsLeft })}
+
+
+
+ );
+};
+
+export default ServicesQuizFinal;
diff --git a/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_1.tsx b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_1.tsx
new file mode 100644
index 0000000000..dd526e5c8e
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_1.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { useMemo } from "react";
+
+import ServicesQuizStepShell from "./services_quiz_step_shell";
+import { SERVICES_QUIZ_CHALLENGES } from "../../constants";
+import ServicesQuizNotesInput from "../fields/services_quiz_notes_input";
+import ServicesQuizToggleChip from "../fields/services_quiz_toggle_chip";
+import { useServicesQuizAnswers } from "../quiz_state/services_quiz_answers_provider";
+
+const ServicesQuizStep1: React.FC = () => {
+ const t = useTranslations();
+ const { state, toggleChallenge, setNotes } = useServicesQuizAnswers();
+
+ const challenges = useMemo(() => {
+ if (!state.category) return [];
+ return SERVICES_QUIZ_CHALLENGES[state.category];
+ }, [state.category]);
+
+ return (
+
+
+ {challenges.map((ch) => (
+ toggleChallenge(ch)}
+ disabled={!state.category}
+ />
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export default ServicesQuizStep1;
diff --git a/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_2.tsx b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_2.tsx
new file mode 100644
index 0000000000..835d798698
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_2.tsx
@@ -0,0 +1,37 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import ServicesQuizStepShell from "./services_quiz_step_shell";
+import ServicesQuizRadioCard from "../fields/services_quiz_radio_card";
+import { useServicesQuizAnswers } from "../quiz_state/services_quiz_answers_provider";
+
+const ServicesQuizStep2: React.FC = () => {
+ const t = useTranslations();
+ const { state, setTiming } = useServicesQuizAnswers();
+
+ const options = [
+ { id: "soon", label: t("soonerThanThreeMonths") },
+ { id: "later", label: t("laterThanThreeMonths") },
+ { id: "flexible", label: t("flexibleUnsure") },
+ ] as const;
+
+ return (
+
+
+ {options.map((opt) => (
+ setTiming(opt.id)}
+ onDeselect={() => setTiming(null)}
+ />
+ ))}
+
+
+ );
+};
+
+export default ServicesQuizStep2;
diff --git a/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_3.tsx b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_3.tsx
new file mode 100644
index 0000000000..e11c49b9d3
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_3.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { FC } from "react";
+
+import ServicesQuizStepShell from "./services_quiz_step_shell";
+import ServicesQuizRadioCard from "../fields/services_quiz_radio_card";
+import ServicesQuizToggleChip from "../fields/services_quiz_toggle_chip";
+import { useServicesQuizAnswers } from "../quiz_state/services_quiz_answers_provider";
+
+type Selection = "pros" | "public" | "experts";
+
+const MULTI_OPTIONS = [
+ { id: "pros", labelKey: "metaculusPros" },
+ { id: "public", labelKey: "public" },
+ { id: "experts", labelKey: "clientExperts" },
+] as const satisfies ReadonlyArray<{
+ id: Selection;
+ labelKey: "metaculusPros" | "public" | "clientExperts";
+}>;
+
+const ServicesQuizStep3: FC = () => {
+ const t = useTranslations();
+ const {
+ state,
+ toggleWhoForecastsSelection,
+ setWhoForecastsNotSure,
+ clearWhoForecasts,
+ } = useServicesQuizAnswers();
+
+ const isNotSure = state.whoForecasts?.mode === "not_sure";
+ const selected =
+ state.whoForecasts?.mode === "selected"
+ ? state.whoForecasts.selections
+ : [];
+
+ return (
+
+
+ {MULTI_OPTIONS.map((opt) => (
+ toggleWhoForecastsSelection(opt.id)}
+ />
+ ))}
+
+
+
+
+ );
+};
+
+export default ServicesQuizStep3;
diff --git a/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_4.tsx b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_4.tsx
new file mode 100644
index 0000000000..36fae10ea2
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_4.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import React from "react";
+
+import ServicesQuizStepShell from "./services_quiz_step_shell";
+import ServicesQuizRadioCard from "../fields/services_quiz_radio_card";
+import { useServicesQuizAnswers } from "../quiz_state/services_quiz_answers_provider";
+
+type PrivacyOption = "semi" | "full" | "flexible";
+
+const ServicesQuizStep4: React.FC = () => {
+ const t = useTranslations();
+ const { state, setPrivacy } = useServicesQuizAnswers();
+
+ const options: Array<{
+ id: PrivacyOption;
+ title: string;
+ description?: string;
+ colSpan?: string;
+ }> = [
+ {
+ id: "semi",
+ title: t("semiConfidentiality"),
+ description: t("semiConfidentialityDescription"),
+ },
+ {
+ id: "full",
+ title: t("fullConfidentiality"),
+ description: t("fullConfidentialityDescription"),
+ },
+ {
+ id: "flexible",
+ title: t("flexibleUnsure"),
+ },
+ ];
+
+ return (
+
+
+ {options.map((opt) => (
+ setPrivacy(opt.id)}
+ onDeselect={() => setPrivacy(null)}
+ />
+ ))}
+
+
+ );
+};
+
+export default ServicesQuizStep4;
diff --git a/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_5.tsx b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_5.tsx
new file mode 100644
index 0000000000..48d2b7272b
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_5.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import React, { useLayoutEffect, useMemo, useRef, useState } from "react";
+
+import { FormErrorMessage, Input, Textarea } from "@/components/ui/form_field";
+import cn from "@/utils/core/cn";
+
+import ServicesQuizStepShell from "./services_quiz_step_shell";
+import { useServicesQuizAnswers } from "../quiz_state/services_quiz_answers_provider";
+import { contactSchema } from "../quiz_state/services_quiz_completion_provider";
+import { useServicesQuizFlow } from "../quiz_state/services_quiz_flow_provider";
+
+type ValidationKey = "fieldRequired" | "invalidEmail";
+
+const ServicesQuizStep5: React.FC = () => {
+ const t = useTranslations();
+ const { goNext, isSubmitting, submitError } = useServicesQuizFlow();
+ const {
+ state,
+ setContactName,
+ setContactEmail,
+ setContactOrg,
+ setContactComments,
+ } = useServicesQuizAnswers();
+
+ const [showErrors, setShowErrors] = useState(false);
+
+ const validation = useMemo(() => {
+ return contactSchema.safeParse({
+ contactName: state.contactName,
+ contactEmail: state.contactEmail,
+ contactOrg: state.contactOrg,
+ contactComments: state.contactComments,
+ });
+ }, [
+ state.contactName,
+ state.contactEmail,
+ state.contactOrg,
+ state.contactComments,
+ ]);
+
+ const errors = useMemo(() => {
+ if (validation.success) return {};
+ const f = validation.error.format();
+ return {
+ contactName: f.contactName?._errors?.[0],
+ contactEmail: f.contactEmail?._errors?.[0],
+ };
+ }, [validation]);
+
+ const commentsRef = useRef(null);
+
+ const MAX_ROWS = 5;
+
+ const syncCommentsHeight = () => {
+ const el = commentsRef.current;
+ if (!el) return;
+
+ const styles = window.getComputedStyle(el);
+
+ const lineHeight = Number.parseFloat(styles.lineHeight || "20");
+ const paddingY =
+ Number.parseFloat(styles.paddingTop || "0") +
+ Number.parseFloat(styles.paddingBottom || "0");
+ const borderY =
+ Number.parseFloat(styles.borderTopWidth || "0") +
+ Number.parseFloat(styles.borderBottomWidth || "0");
+
+ const maxHeight = lineHeight * MAX_ROWS + paddingY + borderY;
+
+ el.style.height = "0px";
+ const nextHeight = Math.min(el.scrollHeight, maxHeight);
+ el.style.height = `${nextHeight}px`;
+ el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
+ };
+
+ const handleSubmit = async () => {
+ setShowErrors(true);
+ if (!validation.success) return;
+ await goNext();
+ };
+
+ useLayoutEffect(() => {
+ syncCommentsHeight();
+ }, [state.contactComments]);
+
+ const inputClassName = cn(
+ "w-full rounded-lg px-3 py-2 text-base leading-5 outline-none transition-colors",
+ "min-h-10",
+ "border border-gray-200 bg-gray-0 text-blue-800 placeholder:text-blue-800/40",
+ "dark:border-gray-200-dark dark:bg-gray-0-dark dark:text-blue-800-dark dark:placeholder:text-blue-800-dark/40",
+ "focus-visible:ring-2 focus-visible:ring-blue-400 dark:focus-visible:ring-blue-400-dark"
+ );
+
+ const labelClassName =
+ "text-sm sm:text-base font-medium leading-5 text-blue-800 dark:text-blue-800-dark";
+
+ return (
+
+
+
+
+
+
+
+
+
+
{t("anyAdditionalComments")}
+
+
+
+
+ {submitError &&
}
+
+
+ );
+};
+
+export default ServicesQuizStep5;
diff --git a/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_shell.tsx b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_shell.tsx
new file mode 100644
index 0000000000..a68cca16ff
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/components/steps/services_quiz_step_shell.tsx
@@ -0,0 +1,33 @@
+import { PropsWithChildren } from "react";
+
+import cn from "@/utils/core/cn";
+
+type Props = PropsWithChildren<{
+ title: string;
+ className?: string;
+}>;
+
+const ServicesQuizStepShell: React.FC = ({
+ children,
+ title,
+ className,
+}) => {
+ return (
+
+
+
+ {title}
+
+
+ {children}
+
+
+ );
+};
+
+export default ServicesQuizStepShell;
diff --git a/front_end/src/app/(services-quiz)/constants.ts b/front_end/src/app/(services-quiz)/constants.ts
new file mode 100644
index 0000000000..cf278b821e
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/constants.ts
@@ -0,0 +1,54 @@
+export type ServicesQuizCategory =
+ | "enterprise"
+ | "government"
+ | "non-profit"
+ | "academia";
+
+export const SERVICES_QUIZ_CHALLENGES: Record =
+ {
+ enterprise: [
+ "Product or feature bets",
+ "AI and automation impact",
+ "Technology timing",
+ "Competitive landscape",
+ "Market size and adoption",
+ "Disruptive shocks and supply chains",
+ ],
+ government: [
+ "Policy options and outcomes",
+ "AI in security, defense, services, etc.",
+ "Crisis and conflict risk",
+ "Public sentiment and legitimacy",
+ "Timing of intervention",
+ "National and cyber threats",
+ ],
+ "non-profit": [
+ "Intervention effectiveness",
+ "AI for monitoring, delivery, and evaluation",
+ "Cause and region prioritization",
+ "Evolving community needs",
+ "Policy and agenda influence",
+ "Donor and partner dynamics",
+ ],
+ academia: [
+ "Academic labor markets and career paths",
+ "AI tools and methods in research",
+ "AI in teaching and student learning",
+ "Funding priorities",
+ "Replication and robustness",
+ "Regulatory risks and opportunities",
+ ],
+ };
+
+export const SERVICES_QUIZ_CATEGORIES: ServicesQuizCategory[] = [
+ "enterprise",
+ "government",
+ "non-profit",
+ "academia",
+];
+
+export function isServicesQuizCategory(
+ v: string | null
+): v is ServicesQuizCategory {
+ return !!v && (SERVICES_QUIZ_CATEGORIES as string[]).includes(v);
+}
diff --git a/front_end/src/app/(services-quiz)/helpers.ts b/front_end/src/app/(services-quiz)/helpers.ts
new file mode 100644
index 0000000000..29fa6be695
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/helpers.ts
@@ -0,0 +1,29 @@
+import { ServicesQuizAnswersState } from "./components/quiz_state/services_quiz_answers_provider";
+
+export type ServicesQuizSubmitPayload = ReturnType<
+ typeof aggregateServicesQuizAnswers
+>;
+
+export function aggregateServicesQuizAnswers(state: ServicesQuizAnswersState) {
+ const name = state.contactName.trim();
+ const email = state.contactEmail.trim();
+
+ if (!name || !email) {
+ throw new Error("Contact name and email are required");
+ }
+
+ return {
+ category: state.category,
+ challenges: state.selectedChallenges,
+ notes: state.notes.trim() || null,
+ timing: state.timing,
+ whoForecasts: state.whoForecasts,
+ privacy: state.privacy,
+ contact: {
+ name,
+ email,
+ organization: state.contactOrg.trim() || null,
+ comments: state.contactComments.trim() || null,
+ },
+ };
+}
diff --git a/front_end/src/app/(services-quiz)/services/quiz/page.tsx b/front_end/src/app/(services-quiz)/services/quiz/page.tsx
new file mode 100644
index 0000000000..43c425bb1e
--- /dev/null
+++ b/front_end/src/app/(services-quiz)/services/quiz/page.tsx
@@ -0,0 +1,19 @@
+import { SearchParams } from "@/types/navigation";
+
+import ServicesQuizScreen from "../../components/services_quiz_screen";
+import { isServicesQuizCategory } from "../../constants";
+
+type Props = {
+ searchParams: Promise;
+};
+
+export default async function ServicesQuizPage(props: Props) {
+ const sp = await props.searchParams;
+
+ const raw =
+ typeof sp.category === "string" && sp.category.length ? sp.category : null;
+
+ const initialCategory = isServicesQuizCategory(raw) ? raw : null;
+
+ return ;
+}
diff --git a/front_end/src/components/flow/flow_exit_confirm_modal.tsx b/front_end/src/components/flow/flow_exit_confirm_modal.tsx
new file mode 100644
index 0000000000..8bcc043322
--- /dev/null
+++ b/front_end/src/components/flow/flow_exit_confirm_modal.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import React, { FC, ReactNode } from "react";
+
+import BaseModal from "@/components/base_modal";
+import Button from "@/components/ui/button";
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+ title: ReactNode;
+ description?: ReactNode;
+ note?: ReactNode;
+ secondaryAction: {
+ label: ReactNode;
+ onClick?: () => void;
+ };
+ primaryAction: {
+ label: ReactNode;
+ onClick: () => void;
+ };
+};
+
+const FlowExitConfirmModal: FC = ({
+ isOpen,
+ onClose,
+ title,
+ description,
+ note,
+ secondaryAction,
+ primaryAction,
+}) => {
+ return (
+
+
+
+ {title}
+
+
+ {description ?
{description}
: null}
+
+ {note ? (
+
+ {note}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default FlowExitConfirmModal;
diff --git a/front_end/src/components/flow/flow_header.tsx b/front_end/src/components/flow/flow_header.tsx
new file mode 100644
index 0000000000..86d6909947
--- /dev/null
+++ b/front_end/src/components/flow/flow_header.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import React, {
+ createContext,
+ FC,
+ PropsWithChildren,
+ ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+
+type Ctx = {
+ title: ReactNode;
+};
+
+const FlowHeaderContext = createContext(null);
+
+const useFlowHeader = () => {
+ const ctx = useContext(FlowHeaderContext);
+ if (!ctx) {
+ throw new Error("useFlowHeader must be used within ");
+ }
+ return ctx;
+};
+
+type RootProps = PropsWithChildren<{
+ title: ReactNode;
+}>;
+
+export const FlowHeaderRoot: FC = ({ title, children }) => {
+ const value = useMemo(() => ({ title }), [title]);
+
+ return (
+
+
+
+ );
+};
+
+export const FlowHeaderBrand: FC<{ children: ReactNode }> = ({ children }) => {
+ return {children}
;
+};
+
+export const FlowHeaderTitle: FC = () => {
+ const { title } = useFlowHeader();
+ return (
+
+ {title}
+
+ );
+};
+
+export const FlowHeaderActions: FC<{ children: ReactNode }> = ({
+ children,
+}) => {
+ return {children}
;
+};
diff --git a/front_end/src/components/flow/flow_stepper.tsx b/front_end/src/components/flow/flow_stepper.tsx
new file mode 100644
index 0000000000..e3c9fe72ed
--- /dev/null
+++ b/front_end/src/components/flow/flow_stepper.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import { faBars, faXmark } from "@fortawesome/free-solid-svg-icons";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ createContext,
+ FC,
+ PropsWithChildren,
+ ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+
+import Button from "@/components/ui/button";
+import cn from "@/utils/core/cn";
+
+export type FlowStepId = string | number;
+
+export type FlowStep = {
+ id: FlowStepId;
+ isDone?: boolean;
+};
+
+type Ctx = {
+ steps: FlowStep[];
+ activeStepId: FlowStepId | null;
+ isMenuOpen: boolean;
+ onToggleMenu: () => void;
+ onSelectStep: (id: FlowStepId) => void;
+};
+
+const FlowStepperContext = createContext(null);
+
+export const useFlowStepper = () => {
+ const ctx = useContext(FlowStepperContext);
+ if (!ctx) {
+ throw new Error("useFlowStepper must be used within ");
+ }
+ return ctx;
+};
+
+type RootProps = PropsWithChildren<{
+ steps: FlowStep[];
+ activeStepId: FlowStepId | null;
+ isMenuOpen: boolean;
+ onToggleMenu: () => void;
+ onSelectStep: (id: FlowStepId) => void;
+}>;
+
+export const FlowStepperRoot: FC = ({
+ steps,
+ activeStepId,
+ isMenuOpen,
+ onToggleMenu,
+ onSelectStep,
+ children,
+}) => {
+ const value = useMemo(
+ () => ({ steps, activeStepId, isMenuOpen, onToggleMenu, onSelectStep }),
+ [steps, activeStepId, isMenuOpen, onToggleMenu, onSelectStep]
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export const FlowStepperHeader: FC<{ label: ReactNode }> = ({ label }) => {
+ return (
+
+ );
+};
+
+export const FlowStepperMenuToggle: FC = () => {
+ const { isMenuOpen, onToggleMenu } = useFlowStepper();
+ return (
+
+ );
+};
+
+export const FlowStepperSegments: FC = () => {
+ const { steps } = useFlowStepper();
+ return (
+
+ {steps.map((s, i) => (
+
+ ))}
+
+ );
+};
+
+type SegmentProps = { stepId: FlowStepId; index: number };
+
+export const FlowStepperSegment: FC = ({ stepId, index }) => {
+ const { steps, activeStepId, onSelectStep } = useFlowStepper();
+ const step = steps.find((s) => s.id === stepId);
+ const isActive = stepId === activeStepId;
+ const isDone = !!step?.isDone;
+
+ return (
+