diff --git a/components/interview/interfaces/name-generator/NameGenerator.tsx b/components/interview/interfaces/name-generator/NameGenerator.tsx index c688dccf..b52db4e1 100644 --- a/components/interview/interfaces/name-generator/NameGenerator.tsx +++ b/components/interview/interfaces/name-generator/NameGenerator.tsx @@ -10,6 +10,7 @@ import { cn } from '~/lib/utils'; import { interfaceWrapperClasses } from '../../ui/SimpleShell'; import { withOnboardingWizard } from '~/components/onboard-wizard/withOnboardingWizard'; import { type InterviewStage } from '../../ui/InterviewShell'; +import { useTranslations } from 'next-intl'; import { type TNodeType } from '~/schemas/protocol/codebook/entities'; const demoPrompts = [ @@ -96,36 +97,42 @@ function NameGenerator(_props: InterviewStage) { ); } -export default withOnboardingWizard(NameGenerator, { - id: 'name-generator', - name: { - en: 'Name Generator Help', - }, - priority: 'Task', - description: { - en: [ +export default withOnboardingWizard(NameGenerator, (_props) => { + // TODO: get the stage id from the props + const stageId = '1'; + + let t = useTranslations(`Protocol.Stages.${stageId}.Wizard`); + + // hacky way to check if the translation exists. This tells us if there are stage-level translations for the wizard + if (t('Name').includes(`Protocol.Stages.${stageId}.Wizard.Name`)) { + // use the default stage type wizard steps. will either be user-supplied or our defaults + t = useTranslations('Interview.Wizards.NameGenerator'); + } + + return { + id: 'name-generator', + name: t('Name'), + priority: 'Task', + description: [ { type: 'paragraph', children: [ { - text: 'Help with the current task, including how to add new people, how to delete people, and how to edit people.', + text: t('Description'), }, ], }, ], - }, - steps: [ - { - title: { - en: 'Welcome to the Name Generator', - }, - content: { - en: [ + steps: [ + { + title: t('Steps.Welcome.Title'), + + content: [ { type: 'paragraph', children: [ { - text: 'This is the name generator interface. This interface allows you to nominate people. First, read the prompt and think about the people who meet the criteria.', + text: t('Steps.Welcome.Text'), }, ], }, @@ -139,60 +146,48 @@ export default withOnboardingWizard(NameGenerator, { }, ], }, - }, - { - targetElementId: 'data-wizard-prompts', - title: { - en: 'Prompts', - }, - content: { - en: [ + { + targetElementId: 'data-wizard-prompts', + title: t('Steps.Prompts.Title'), + content: [ { type: 'paragraph', children: [ { - text: 'These are the prompts. They help you think about the people you want to nominate.', + text: t('Steps.Prompts.Text'), }, ], }, ], }, - }, - { - targetElementId: 'data-wizard-task-step-2', - title: { - en: 'Side Panels', - }, - content: { - en: [ + { + targetElementId: 'data-wizard-task-step-2', + title: t('Steps.SidePanels.Title'), + content: [ { type: 'paragraph', children: [ { - text: 'These are side panels. They show the people you have already mentioned. You can drag and drop a person into the main area to nominate them.', + text: t('Steps.SidePanels.Text'), }, ], }, ], }, - }, - { - targetElementId: 'data-wizard-task-step-3', - title: { - en: 'Adding a person', - }, - content: { - en: [ + { + targetElementId: 'data-wizard-task-step-3', + title: t('Steps.AddPerson.Title'), + content: [ { type: 'paragraph', children: [ { - text: 'Click this button to add a new person', + text: t('Steps.AddPerson.Text'), }, ], }, ], }, - }, - ], + ], + }; }); diff --git a/components/interview/ui/HelpButton.tsx b/components/interview/ui/HelpButton.tsx index ca5f34f6..7634218d 100644 --- a/components/interview/ui/HelpButton.tsx +++ b/components/interview/ui/HelpButton.tsx @@ -1,10 +1,9 @@ import { HelpCircle } from 'lucide-react'; import { NavButtonWithTooltip } from './NavigationButton'; -import { useLocale, useTranslations } from 'next-intl'; +import { useTranslations } from 'next-intl'; import { useWizardController } from '~/components/onboard-wizard/useWizardController'; import { env } from '~/env'; import { WIZARD_LOCAL_STORAGE_KEY } from '~/lib/onboarding-wizard/Provider'; -import { getLocalisedValue } from '~/lib/localisation/utils'; import { renderLocalisedValue } from '~/components/RenderRichText'; import { useDialog } from '~/lib/dialogs/DialogProvider'; import { Button } from '~/components/ui/Button'; @@ -17,7 +16,6 @@ import Form from '~/components/ui/form/Form'; export default function HelpButton({ id }: { id?: string }) { const { openDialog } = useDialog(); const { wizards, setActiveWizard } = useWizardController(); - const locale = useLocale(); const t = useTranslations('Interview.Navigation'); const t2 = useTranslations('Components.ContextualHelp'); @@ -40,12 +38,10 @@ export default function HelpButton({ id }: { id?: string }) { .map((wizard) => ( resolve(wizard.id)} > - {renderLocalisedValue( - getLocalisedValue(wizard.description, locale), - )} + {renderLocalisedValue(wizard.description)} ))} { + const t = useTranslations('Interview.Wizards.General'); + + return { + id: 'general-interview-information', + name: t('Name'), + priority: 'Navigation', + description: [ { type: 'paragraph', children: [ { - text: 'Help with the general interview process, including how to navigate the interview, and general tips to help you get started.', + text: t('Description'), }, ], }, ], - }, - steps: [ - { - title: { - en: 'Welcome to the Interview!', - }, - content: { - en: [ + steps: [ + { + title: t('Steps.Welcome.Title'), + content: [ { type: 'paragraph', children: [ { - text: 'Before you begin, here are some general tips to help you navigate the interview process.', + text: t('Steps.Welcome.Text'), }, ], }, ], }, - }, - { - targetElementId: 'navigation-bar', - title: { - en: 'Navigation Bar', - }, - content: { - en: [ + { + targetElementId: 'navigation-bar', + title: t('Steps.NavigationBar.Title'), + content: [ { type: 'paragraph', children: [ { - text: 'The navigation bar helps you move through the interview process, and get help if you need it.', + text: t('Steps.NavigationBar.Text'), }, ], }, @@ -161,24 +153,20 @@ export default withOnboardingWizard(Navigation, { }, ], }, - }, - { - targetElementId: 'interview-movement', - title: { - en: 'Navigating the Interview', - }, - content: { - en: [ + { + targetElementId: 'interview-movement', + title: t('Steps.InterviewMovement.Title'), + content: [ { type: 'paragraph', children: [ { - text: 'Use the back and forward buttons to move through the interview, and track your progress using the progress bar.', + text: t('Steps.InterviewMovement.Text'), }, ], }, ], }, - }, - ], + ], + }; }); diff --git a/components/onboard-wizard/WizardStep.tsx b/components/onboard-wizard/WizardStep.tsx index 754762ae..0758c5ce 100644 --- a/components/onboard-wizard/WizardStep.tsx +++ b/components/onboard-wizard/WizardStep.tsx @@ -2,19 +2,15 @@ import Popover from '~/components/ui/Popover'; import { Button } from '../ui/Button'; import { useWizardController } from './useWizardController'; import RenderRichText from '../RenderRichText'; -import { useLocale, useTranslations } from 'next-intl'; +import { useTranslations } from 'next-intl'; import { useElementPosition } from '~/lib/onboarding-wizard/utils'; import { type Step } from '~/lib/onboarding-wizard/store'; -import { getLocalisedValue } from '~/lib/localisation/utils'; import Form from '../ui/form/Form'; import { generatePublicId } from '~/lib/generatePublicId'; import { ControlledDialog } from '~/lib/dialogs/ControlledDialog'; export default function WizardStep({ step }: { step: Step }) { const { title, content, targetElementId } = step; - const locale = useLocale(); - const localisedStepContent = getLocalisedValue(content, locale); - const localisedStepTitle = getLocalisedValue(title, locale); const { closeWizard, @@ -30,7 +26,7 @@ export default function WizardStep({ step }: { step: Step }) { const renderContent = () => ( <> - + {renderContent()} diff --git a/components/onboard-wizard/withOnboardingWizard.tsx b/components/onboard-wizard/withOnboardingWizard.tsx index 191af5bb..0897273d 100644 --- a/components/onboard-wizard/withOnboardingWizard.tsx +++ b/components/onboard-wizard/withOnboardingWizard.tsx @@ -4,9 +4,10 @@ import OnboardWizard from './OnboardWizard'; // A HOC that wraps a stage with an onboarding wizard export function withOnboardingWizard( WrappedComponent: React.ComponentType, - wizard: Wizard, + getWizard: (props: P) => Wizard, // function to create a wizard ) { const WithOnboardingWizard = (props: P) => { + const wizard = getWizard(props); return ( <> diff --git a/lib/db/sample-data/dev-protocol.ts b/lib/db/sample-data/dev-protocol.ts index ab5d6c28..b18a1291 100644 --- a/lib/db/sample-data/dev-protocol.ts +++ b/lib/db/sample-data/dev-protocol.ts @@ -51,6 +51,28 @@ export const devProtocol: Protocol = { Stages: { '1': { Label: 'Générateur de noms', + Wizard: { + Name: 'French wizard stage specific override', + Description: 'Description!', + Steps: { + Welcome: { + Title: 'Title - Welcome override', + Text: 'Stage specific override', + }, + Prompts: { + Title: 'Title - Prompts override', + Text: 'Stage specific override', + }, + SidePanels: { + Title: 'Title - Side Panels override', + Text: 'Stage specific override', + }, + AddPerson: { + Title: 'Title - Add Person override', + Text: 'Stage specific override', + }, + }, + }, }, }, Prompts: { @@ -66,6 +88,20 @@ export const devProtocol: Protocol = { }, }, }, + Interview: { + Wizards: { + General: { + Name: "Informations générales sur l'entretien", + Description: "Description de l'étape", + Steps: { + Welcome: { + Title: 'Bienvenue', + Text: "Bienvenue dans l'entretien", + }, + }, + }, + }, + }, }, }, codebook: { diff --git a/lib/localisation/locale.ts b/lib/localisation/locale.ts index cc097721..30a29d9e 100644 --- a/lib/localisation/locale.ts +++ b/lib/localisation/locale.ts @@ -14,6 +14,7 @@ import Negotiator from 'negotiator'; import { match } from '@formatjs/intl-localematcher'; import { getCurrentPath, getInterviewId } from '../serverUtils'; import type { ProtocolMessages } from '~/schemas/protocol/protocol'; +import deepmerge from 'deepmerge'; async function getProtocolLocales(interviewId: string): Promise { try { @@ -122,10 +123,9 @@ export async function getLocaleMessages( )) as { default: IntlMessages; }; - return { - ...mainMessages.default, - ...protocolMessages, - }; + + return deepmerge(mainMessages.default, protocolMessages); + } export async function setUserLocale(locale: Locale) { diff --git a/lib/localisation/messages/en.json b/lib/localisation/messages/en.json index e452ac4b..12ad3523 100644 --- a/lib/localisation/messages/en.json +++ b/lib/localisation/messages/en.json @@ -95,6 +95,52 @@ "LanguageSwitcher": "Change interview language", "Help": "Help menu", "Progress": "You have completed {percent}% of the interview" + }, + "Wizards": { + "General": { + "Name": "General Interview Information", + "Description": "Help with the general interview process, including how to navigate the interview, and general tips to help you get started.", + "Steps": { + "Welcome": { + "Title": "Welcome to the interview", + "Text": "Before you begin, here are some general tips to help you navigate the interview process." + }, + "NavigationBar": { + "Title": "Navigation Bar", + "Text": "The navigation bar helps you move through the interview process, and get help if you need it." + }, + "InterviewMovement": { + "Title": "Navigating the Interview", + "Text": "This is the third step of the interview. Here you will be introduced to the interview process and given an overview of what to expect." + }, + "Summary": { + "Title": "Summary", + "Text": "Use the back and forward buttons to move through the interview, and track your progress using the progress bar." + } + } + }, + "NameGenerator": { + "Name": "Name Generator Help", + "Description": "Help with the current task, including how to add new people, how to delete people, and how to edit people.", + "Steps": { + "Welcome": { + "Title": "Welcome to the Name Generator", + "Text": "This is the name generator interface. This interface allows you to nominate people. First, read the prompt and think about the people who meet the criteria." + }, + "Prompts": { + "Title": "Prompts", + "Text": "These are the prompts. They help you think about the people you want to nominate." + }, + "SidePanels": { + "Title": "Side Panels", + "Text": "These are side panels. They show the people you have already mentioned. You can drag and drop a person into the main area to nominate them." + }, + "AddPerson": { + "Title": "Adding a person", + "Text": "Click this button to add a new person" + } + } + } } }, "Generic": { diff --git a/lib/onboarding-wizard/store.ts b/lib/onboarding-wizard/store.ts index f3d0945f..47f43c13 100644 --- a/lib/onboarding-wizard/store.ts +++ b/lib/onboarding-wizard/store.ts @@ -1,5 +1,5 @@ import { createStore } from 'zustand'; -import type { LocalisedString, LocalisedRecord } from '~/schemas/shared'; +import type { JSONRichText } from '~/schemas/shared'; import { type LocalStorageState } from '~/lib/createLocalStorageStore'; const Priorities = { @@ -14,14 +14,14 @@ type Priority = keyof typeof Priorities; export type Step = { targetElementId?: string; - title: LocalisedString; - content: LocalisedRecord; + title: string; + content: JSONRichText; }; export type Wizard = { id: string; - name: LocalisedString; - description: LocalisedRecord; + name: string; + description: JSONRichText; steps: Step[]; priority: Priority; }; diff --git a/package.json b/package.json index bbab6ce0..78196f82 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@vercel/analytics": "^1.3.1", "@vercel/kv": "^2.0.0", "clsx": "^2.1.1", + "deepmerge": "^4.3.1", "eslint-config-prettier": "^9.1.0", "framer-motion": "12.0.0-alpha.1", "knip": "^5.30.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6cc96c0..a0fa3c32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@8.57.0) diff --git a/schemas/protocol/protocol.ts b/schemas/protocol/protocol.ts index 81a2f560..fe9ff80d 100644 --- a/schemas/protocol/protocol.ts +++ b/schemas/protocol/protocol.ts @@ -12,6 +12,19 @@ export const ProtocolMessagesSchema = z.object({ z.string(), z.object({ Label: z.string(), + Wizard: z + .object({ + Name: z.string(), + Description: z.string(), + Steps: z.record( + z.string(), + z.object({ + Title: z.string(), + Text: z.string(), + }), + ), + }) + .optional(), // Stage-specific overrides are optional }), ), Panels: z @@ -24,6 +37,36 @@ export const ProtocolMessagesSchema = z.object({ .optional(), Prompts: z.record(z.string(), z.string()).optional(), }), + Interview: z + .object({ + Wizards: z.object({ + General: z.object({ + Name: z.string().optional(), + Description: z.string().optional(), + Steps: z.object({ + Welcome: z + .object({ + Title: z.string(), + Text: z.string(), + }) + .optional(), + Overview: z + .object({ + Title: z.string(), + Text: z.string(), + }) + .optional(), + Summary: z + .object({ + Title: z.string(), + Text: z.string(), + }) + .optional(), + }), + }), + }), + }) + .optional(), }); const LocalisedStringsSchema = z.record(
( WrappedComponent: React.ComponentType
, - wizard: Wizard, + getWizard: (props: P) => Wizard, // function to create a wizard ) { const WithOnboardingWizard = (props: P) => { + const wizard = getWizard(props); return ( <> diff --git a/lib/db/sample-data/dev-protocol.ts b/lib/db/sample-data/dev-protocol.ts index ab5d6c28..b18a1291 100644 --- a/lib/db/sample-data/dev-protocol.ts +++ b/lib/db/sample-data/dev-protocol.ts @@ -51,6 +51,28 @@ export const devProtocol: Protocol = { Stages: { '1': { Label: 'Générateur de noms', + Wizard: { + Name: 'French wizard stage specific override', + Description: 'Description!', + Steps: { + Welcome: { + Title: 'Title - Welcome override', + Text: 'Stage specific override', + }, + Prompts: { + Title: 'Title - Prompts override', + Text: 'Stage specific override', + }, + SidePanels: { + Title: 'Title - Side Panels override', + Text: 'Stage specific override', + }, + AddPerson: { + Title: 'Title - Add Person override', + Text: 'Stage specific override', + }, + }, + }, }, }, Prompts: { @@ -66,6 +88,20 @@ export const devProtocol: Protocol = { }, }, }, + Interview: { + Wizards: { + General: { + Name: "Informations générales sur l'entretien", + Description: "Description de l'étape", + Steps: { + Welcome: { + Title: 'Bienvenue', + Text: "Bienvenue dans l'entretien", + }, + }, + }, + }, + }, }, }, codebook: { diff --git a/lib/localisation/locale.ts b/lib/localisation/locale.ts index cc097721..30a29d9e 100644 --- a/lib/localisation/locale.ts +++ b/lib/localisation/locale.ts @@ -14,6 +14,7 @@ import Negotiator from 'negotiator'; import { match } from '@formatjs/intl-localematcher'; import { getCurrentPath, getInterviewId } from '../serverUtils'; import type { ProtocolMessages } from '~/schemas/protocol/protocol'; +import deepmerge from 'deepmerge'; async function getProtocolLocales(interviewId: string): Promise { try { @@ -122,10 +123,9 @@ export async function getLocaleMessages( )) as { default: IntlMessages; }; - return { - ...mainMessages.default, - ...protocolMessages, - }; + + return deepmerge(mainMessages.default, protocolMessages); + } export async function setUserLocale(locale: Locale) { diff --git a/lib/localisation/messages/en.json b/lib/localisation/messages/en.json index e452ac4b..12ad3523 100644 --- a/lib/localisation/messages/en.json +++ b/lib/localisation/messages/en.json @@ -95,6 +95,52 @@ "LanguageSwitcher": "Change interview language", "Help": "Help menu", "Progress": "You have completed {percent}% of the interview" + }, + "Wizards": { + "General": { + "Name": "General Interview Information", + "Description": "Help with the general interview process, including how to navigate the interview, and general tips to help you get started.", + "Steps": { + "Welcome": { + "Title": "Welcome to the interview", + "Text": "Before you begin, here are some general tips to help you navigate the interview process." + }, + "NavigationBar": { + "Title": "Navigation Bar", + "Text": "The navigation bar helps you move through the interview process, and get help if you need it." + }, + "InterviewMovement": { + "Title": "Navigating the Interview", + "Text": "This is the third step of the interview. Here you will be introduced to the interview process and given an overview of what to expect." + }, + "Summary": { + "Title": "Summary", + "Text": "Use the back and forward buttons to move through the interview, and track your progress using the progress bar." + } + } + }, + "NameGenerator": { + "Name": "Name Generator Help", + "Description": "Help with the current task, including how to add new people, how to delete people, and how to edit people.", + "Steps": { + "Welcome": { + "Title": "Welcome to the Name Generator", + "Text": "This is the name generator interface. This interface allows you to nominate people. First, read the prompt and think about the people who meet the criteria." + }, + "Prompts": { + "Title": "Prompts", + "Text": "These are the prompts. They help you think about the people you want to nominate." + }, + "SidePanels": { + "Title": "Side Panels", + "Text": "These are side panels. They show the people you have already mentioned. You can drag and drop a person into the main area to nominate them." + }, + "AddPerson": { + "Title": "Adding a person", + "Text": "Click this button to add a new person" + } + } + } } }, "Generic": { diff --git a/lib/onboarding-wizard/store.ts b/lib/onboarding-wizard/store.ts index f3d0945f..47f43c13 100644 --- a/lib/onboarding-wizard/store.ts +++ b/lib/onboarding-wizard/store.ts @@ -1,5 +1,5 @@ import { createStore } from 'zustand'; -import type { LocalisedString, LocalisedRecord } from '~/schemas/shared'; +import type { JSONRichText } from '~/schemas/shared'; import { type LocalStorageState } from '~/lib/createLocalStorageStore'; const Priorities = { @@ -14,14 +14,14 @@ type Priority = keyof typeof Priorities; export type Step = { targetElementId?: string; - title: LocalisedString; - content: LocalisedRecord; + title: string; + content: JSONRichText; }; export type Wizard = { id: string; - name: LocalisedString; - description: LocalisedRecord; + name: string; + description: JSONRichText; steps: Step[]; priority: Priority; }; diff --git a/package.json b/package.json index bbab6ce0..78196f82 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@vercel/analytics": "^1.3.1", "@vercel/kv": "^2.0.0", "clsx": "^2.1.1", + "deepmerge": "^4.3.1", "eslint-config-prettier": "^9.1.0", "framer-motion": "12.0.0-alpha.1", "knip": "^5.30.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6cc96c0..a0fa3c32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 eslint-config-prettier: specifier: ^9.1.0 version: 9.1.0(eslint@8.57.0) diff --git a/schemas/protocol/protocol.ts b/schemas/protocol/protocol.ts index 81a2f560..fe9ff80d 100644 --- a/schemas/protocol/protocol.ts +++ b/schemas/protocol/protocol.ts @@ -12,6 +12,19 @@ export const ProtocolMessagesSchema = z.object({ z.string(), z.object({ Label: z.string(), + Wizard: z + .object({ + Name: z.string(), + Description: z.string(), + Steps: z.record( + z.string(), + z.object({ + Title: z.string(), + Text: z.string(), + }), + ), + }) + .optional(), // Stage-specific overrides are optional }), ), Panels: z @@ -24,6 +37,36 @@ export const ProtocolMessagesSchema = z.object({ .optional(), Prompts: z.record(z.string(), z.string()).optional(), }), + Interview: z + .object({ + Wizards: z.object({ + General: z.object({ + Name: z.string().optional(), + Description: z.string().optional(), + Steps: z.object({ + Welcome: z + .object({ + Title: z.string(), + Text: z.string(), + }) + .optional(), + Overview: z + .object({ + Title: z.string(), + Text: z.string(), + }) + .optional(), + Summary: z + .object({ + Title: z.string(), + Text: z.string(), + }) + .optional(), + }), + }), + }), + }) + .optional(), }); const LocalisedStringsSchema = z.record(