From 45065ad86ea834b0017e4583a1cd636ecf41ab6e Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 08:28:58 -0500 Subject: [PATCH 01/14] Fix process builder save bug (#678) The store was being seeded every time a section loads, resulting in stale data. Probably need a more thorough refactor, but this fix ensures the store hydrates only once per page load. This also updates the create process instance e2e test --- .../ProcessBuilderStoreInitializer.tsx | 13 ++++++ .../e2e/tests/create-process-instance.spec.ts | 45 ++++++++++--------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx index 6bc828346..4c4b5a4a9 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx @@ -35,8 +35,21 @@ export function ProcessBuilderStoreInitializer({ const serverDataRef = useRef(serverData); serverDataRef.current = serverData; + // Guard against re-seeding when other components call rehydrate(), + // which re-fires all onFinishHydration listeners. Without this, + // navigating between sections would overwrite user edits with stale + // server data from the initial page load. + const hasSeeded = useRef(false); + useEffect(() => { + hasSeeded.current = false; + const unsubscribe = useProcessBuilderStore.persist.onFinishHydration(() => { + if (hasSeeded.current) { + return; + } + hasSeeded.current = true; + const existing = useProcessBuilderStore.getState().instances[decisionProfileId]; diff --git a/tests/e2e/tests/create-process-instance.spec.ts b/tests/e2e/tests/create-process-instance.spec.ts index eac8b0533..f5953563b 100644 --- a/tests/e2e/tests/create-process-instance.spec.ts +++ b/tests/e2e/tests/create-process-instance.spec.ts @@ -3,6 +3,15 @@ import { expect, test } from '../fixtures/index.js'; // Use a wider viewport so the participant preview panel (xl:block >= 1280px) is visible. test.use({ viewport: { width: 1440, height: 900 } }); +/** Resolves when the next updateDecisionInstance mutation succeeds. */ +function waitForAutoSave(page: import('@playwright/test').Page) { + return page.waitForResponse( + (resp) => + resp.url().includes('decision.updateDecisionInstance') && resp.ok(), + { timeout: 10000 }, + ); +} + test.describe('Create Process Instance', () => { test('can create a decision process and reach launch-ready state', async ({ authenticatedPage, @@ -33,17 +42,13 @@ test.describe('Create Process Instance', () => { // 4. Fill in the process name await authenticatedPage.getByLabel('Process Name').fill('E2E Test Process'); - // 5. Fill in the description + // 5. Fill in the description — start listening for the auto-save response + // before the final input so we don't miss the debounced mutation + const overviewSaved = waitForAutoSave(authenticatedPage); await authenticatedPage .getByLabel('Description') .fill('A process created by E2E tests'); - - // Wait for the debounced auto-save (1000ms) to flush to the API. - // For draft instances, the store initializer always prefers server data, - // so we reload after each section to get fresh server state. - // eslint-disable-next-line playwright/no-wait-for-timeout - await authenticatedPage.waitForTimeout(2000); - await authenticatedPage.reload({ waitUntil: 'networkidle' }); + await overviewSaved; // ── Step 1: General – Phases ──────────────────────────────────────── @@ -101,10 +106,9 @@ test.describe('Create Process Instance', () => { await endDateInput.press('Enter'); } - // Wait for phases auto-save to flush, then reload - // eslint-disable-next-line playwright/no-wait-for-timeout - await authenticatedPage.waitForTimeout(2000); - await authenticatedPage.reload({ waitUntil: 'networkidle' }); + // Wait for the phases auto-save to complete + const phasesSaved = waitForAutoSave(authenticatedPage); + await phasesSaved; // ── Step 1: General – Proposal Categories ─────────────────────────── @@ -129,6 +133,8 @@ test.describe('Create Process Instance', () => { .getByLabel('Full description') .fill('Expand access to quality education in underserved communities'); + // Start listening before the action that triggers auto-save + const categorySaved = waitForAutoSave(authenticatedPage); await authenticatedPage .getByRole('button', { name: 'Add category' }) .click(); @@ -138,9 +144,7 @@ test.describe('Create Process Instance', () => { authenticatedPage.getByText('Education', { exact: true }), ).toBeVisible(); - // Wait for category auto-save to flush - // eslint-disable-next-line playwright/no-wait-for-timeout - await authenticatedPage.waitForTimeout(1500); + await categorySaved; // ── Step 2: Proposal Template ─────────────────────────────────────── @@ -155,6 +159,7 @@ test.describe('Create Process Instance', () => { ).toBeVisible({ timeout: 10000 }); // 11. Enable the Budget field in the template + const templateSaved = waitForAutoSave(authenticatedPage); await authenticatedPage .getByRole('button', { name: 'Show in template?' }) .click(); @@ -174,15 +179,11 @@ test.describe('Create Process Instance', () => { authenticatedPage.getByRole('button', { name: 'Add budget' }), ).toBeVisible({ timeout: 5000 }); - // ── Final: Verify Launch Process button is enabled ────────────────── + await templateSaved; - // 13. Wait for template auto-save to flush, then reload so the store - // initializer picks up all saved data from the server - // eslint-disable-next-line playwright/no-wait-for-timeout - await authenticatedPage.waitForTimeout(2000); - await authenticatedPage.reload({ waitUntil: 'networkidle' }); + // ── Final: Verify Launch Process button is enabled ────────────────── - // 14. Verify the Launch Process button is enabled (not disabled) + // 13. Verify the Launch Process button is enabled (not disabled) const launchButton = authenticatedPage.getByRole('button', { name: 'Launch Process', }); From 4e42b11d906450b0bf99a7f8270848b6e7857399 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Fri, 27 Feb 2026 08:46:33 -0500 Subject: [PATCH 02/14] Align Process Builder store types with backend types (#668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Nest flat config fields (`categories`, `requireCategorySelection`, `allowMultipleCategories`, `organizeByCategories`, `requireCollaborativeProposals`, `isPrivate`) under a `config` object in the process builder store, aligning the client-side shape with the server's `InstanceData.config` - Rename `FormInstanceData` → `ProcessBuilderInstanceData` and replace local `ProposalTemplate` type alias with `ProposalTemplateSchema` from `@op/common` - Add config-aware shallow merge in `setInstanceData` and the store initializer so per-section partial config updates don't clobber each other --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213454016355586 --- .../decisions/[slug]/edit/page.tsx | 18 ++--- .../ProcessBuilder/LaunchProcessModal.tsx | 2 +- .../ProcessBuilder/ProcessBuilderHeader.tsx | 8 +- .../ProcessBuilderStoreInitializer.tsx | 33 ++++---- .../general/OverviewSectionForm.tsx | 14 ++-- .../ProposalCategoriesSectionContent.tsx | 14 ++-- .../template/ParticipantPreview.tsx | 2 +- .../template/TemplateEditorContent.tsx | 10 +-- .../stores/useProcessBuilderStore.ts | 81 +++++++------------ .../validation/processBuilderValidation.ts | 14 ++-- .../components/decisions/proposalTemplate.ts | 12 ++- 11 files changed, 91 insertions(+), 117 deletions(-) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx index e01b17a1c..af3e36833 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx @@ -5,7 +5,7 @@ import { ProcessBuilderContent } from '@/components/decisions/ProcessBuilder/Pro import { ProcessBuilderHeader } from '@/components/decisions/ProcessBuilder/ProcessBuilderHeader'; import { ProcessBuilderSidebar } from '@/components/decisions/ProcessBuilder/ProcessBuilderSectionNav'; import { ProcessBuilderStoreInitializer } from '@/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer'; -import type { FormInstanceData } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; +import type { ProcessBuilderInstanceData } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; const EditDecisionPage = async ({ params, @@ -28,23 +28,15 @@ const EditDecisionPage = async ({ const instanceId = processInstance.id; const instanceData = processInstance.instanceData; - // Map server data into the shape the store expects so validation works - // immediately — even before the user visits any section. - const serverData: FormInstanceData = { + // Seed the store with server data so validation works immediately. + const serverData: ProcessBuilderInstanceData = { name: processInstance.name ?? undefined, description: processInstance.description ?? undefined, stewardProfileId: processInstance.steward?.id, phases: instanceData.phases, proposalTemplate: - instanceData.proposalTemplate as FormInstanceData['proposalTemplate'], - hideBudget: instanceData.config?.hideBudget, - categories: instanceData.config?.categories, - requireCategorySelection: instanceData.config?.requireCategorySelection, - allowMultipleCategories: instanceData.config?.allowMultipleCategories, - organizeByCategories: instanceData.config?.organizeByCategories, - requireCollaborativeProposals: - instanceData.config?.requireCollaborativeProposals, - isPrivate: instanceData.config?.isPrivate, + instanceData.proposalTemplate as ProcessBuilderInstanceData['proposalTemplate'], + config: instanceData.config, }; return ( diff --git a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx index 891f29c34..bf2aaecd2 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/LaunchProcessModal.tsx @@ -34,7 +34,7 @@ export const LaunchProcessModal = ({ ); const phasesCount = instanceData?.phases?.length ?? 0; - const categoriesCount = instanceData?.categories?.length ?? 0; + const categoriesCount = instanceData?.config?.categories?.length ?? 0; const showNoCategoriesWarning = categoriesCount === 0; const updateInstance = trpc.decision.updateDecisionInstance.useMutation({ diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index 7d1b65804..a58b4e1b2 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -151,13 +151,7 @@ const ProcessBuilderHeaderContent = ({ stewardProfileId: storeData?.stewardProfileId || undefined, phases: storeData?.phases, proposalTemplate: storeData?.proposalTemplate, - config: storeData?.categories - ? { - categories: storeData.categories, - requireCategorySelection: storeData.requireCategorySelection, - allowMultipleCategories: storeData.allowMultipleCategories, - } - : undefined, + config: storeData?.config, }); } }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx index 4c4b5a4a9..0824171d6 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { - type FormInstanceData, + type ProcessBuilderInstanceData, useProcessBuilderStore, } from './stores/useProcessBuilderStore'; @@ -16,8 +16,8 @@ import { * - Draft: server data is used directly (localStorage is ignored to avoid * stale edits overwriting already-saved data). * - Non-draft: server data is the base layer, localStorage edits overlay - * on top for keys with a defined, non-empty value (since not all fields - * are persisted to the API yet). + * on top for keys with a defined, non-empty value (since non-draft + * processes do not save to the API on every edit). * * Note: `isDraft` is evaluated once from the server component at page load. * This assumes launching a process triggers a navigation/reload so the @@ -29,7 +29,7 @@ export function ProcessBuilderStoreInitializer({ isDraft, }: { decisionProfileId: string; - serverData: FormInstanceData; + serverData: ProcessBuilderInstanceData; isDraft: boolean; }) { const serverDataRef = useRef(serverData); @@ -58,19 +58,24 @@ export function ProcessBuilderStoreInitializer({ // For drafts, prefer server data — localStorage may contain stale // edits from a previous session that have already been saved. // For non-draft (launched) processes, overlay localStorage on top - // since not all fields are persisted to the API yet. - let data: FormInstanceData; + // since edits are only buffered locally until explicitly saved. + let data: ProcessBuilderInstanceData; if (isDraft) { data = base; } else { - data = { ...base }; - if (existing) { - for (const [key, value] of Object.entries(existing)) { - if (value !== undefined && value !== '') { - (data as Record)[key] = value; - } - } - } + // Filter out empty/undefined localStorage values, then overlay + // on top of server data so local edits take precedence. + const { config: localConfig, ...localRest } = existing ?? {}; + const filtered = Object.fromEntries( + Object.entries(localRest).filter( + ([, v]) => v !== undefined && v !== '', + ), + ); + data = { + ...base, + ...filtered, + config: { ...base.config, ...localConfig }, + }; } useProcessBuilderStore diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx index 277ca2777..bc441088d 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx @@ -128,9 +128,11 @@ export function OverviewSectionForm({ name: values.name, description: values.description, stewardProfileId: values.stewardProfileId, - organizeByCategories: values.organizeByCategories, - requireCollaborativeProposals: values.requireCollaborativeProposals, - isPrivate: values.isPrivate, + config: { + organizeByCategories: values.organizeByCategories, + requireCollaborativeProposals: values.requireCollaborativeProposals, + isPrivate: values.isPrivate, + }, }); if (isDraft) { @@ -164,10 +166,10 @@ export function OverviewSectionForm({ stewardProfileId: initialStewardProfileId, name: initialName, description: initialDescription, - organizeByCategories: instanceData?.organizeByCategories ?? true, + organizeByCategories: instanceData?.config?.organizeByCategories ?? true, requireCollaborativeProposals: - instanceData?.requireCollaborativeProposals ?? true, - isPrivate: instanceData?.isPrivate ?? false, + instanceData?.config?.requireCollaborativeProposals ?? true, + isPrivate: instanceData?.config?.isPrivate ?? false, }, validators: { onBlur: createOverviewValidator(t), diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx index 081003f4f..cb4f13d90 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx @@ -43,8 +43,8 @@ export function ProposalCategoriesSectionContent({ (s) => s.instances[decisionProfileId], ); const setInstanceData = useProcessBuilderStore((s) => s.setInstanceData); - const setProposalTemplate = useProcessBuilderStore( - (s) => s.setProposalTemplate, + const setProposalTemplateSchema = useProcessBuilderStore( + (s) => s.setProposalTemplateSchema, ); const setSaveStatus = useProcessBuilderStore((s) => s.setSaveStatus); const markSaved = useProcessBuilderStore((s) => s.markSaved); @@ -52,13 +52,13 @@ export function ProposalCategoriesSectionContent({ // Local state — immediate source of truth for UI // Seed from store (localStorage) first, then fall back to server data const [config, setConfig] = useState(() => ({ - categories: storeData?.categories ?? serverConfig?.categories ?? [], + categories: storeData?.config?.categories ?? serverConfig?.categories ?? [], requireCategorySelection: - storeData?.requireCategorySelection ?? + storeData?.config?.requireCategorySelection ?? serverConfig?.requireCategorySelection ?? true, allowMultipleCategories: - storeData?.allowMultipleCategories ?? + storeData?.config?.allowMultipleCategories ?? serverConfig?.allowMultipleCategories ?? false, })); @@ -88,7 +88,7 @@ export function ProposalCategoriesSectionContent({ // not currently mounted. const debouncedSave = useDebouncedCallback((data: CategoryConfig) => { setSaveStatus(decisionProfileId, 'saving'); - setInstanceData(decisionProfileId, data); + setInstanceData(decisionProfileId, { config: data }); const existingTemplate = instance.instanceData.proposalTemplate; @@ -105,7 +105,7 @@ export function ProposalCategoriesSectionContent({ requireCategorySelection: data.requireCategorySelection, }); mutation.proposalTemplate = syncedTemplate; - setProposalTemplate(decisionProfileId, syncedTemplate); + setProposalTemplateSchema(decisionProfileId, syncedTemplate); } if (isDraft) { diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx index d620486f1..35e705663 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx @@ -22,7 +22,7 @@ const EMPTY_DRAFT: ProposalDraftFields = { /** * Live participant preview panel shown alongside the template builder. * - * Converts the builder's `ProposalTemplate` into compiled field descriptors + * Converts the builder's `ProposalTemplateSchema` into compiled field descriptors * and renders them via `ProposalFormRenderer` in static preview mode — no * Yjs, TipTap, or collaboration providers are created. */ diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx index 46d9cf0f8..3f739c314 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx @@ -70,10 +70,10 @@ export function TemplateEditorContent({ (s) => s.instances[decisionProfileId], ); const rawCategories = - storeData?.categories ?? instanceData?.config?.categories; + storeData?.config?.categories ?? instanceData?.config?.categories; const categories = useMemo(() => rawCategories ?? [], [rawCategories]); const requireCategorySelection = - storeData?.requireCategorySelection ?? + storeData?.config?.requireCategorySelection ?? instanceData?.config?.requireCategorySelection ?? false; const hasCategories = categories.length > 0; @@ -128,8 +128,8 @@ export function TemplateEditorContent({ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const sidebarOpen = isMobile ? mobileSidebarOpen : true; - const setProposalTemplate = useProcessBuilderStore( - (s) => s.setProposalTemplate, + const setProposalTemplateSchema = useProcessBuilderStore( + (s) => s.setProposalTemplateSchema, ); const setSaveStatus = useProcessBuilderStore((s) => s.setSaveStatus); const markSaved = useProcessBuilderStore((s) => s.markSaved); @@ -201,7 +201,7 @@ export function TemplateEditorContent({ categories, requireCategorySelection, }); - setProposalTemplate(decisionProfileId, normalized); + setProposalTemplateSchema(decisionProfileId, normalized); if (isDraft) { updateInstance.mutate({ diff --git a/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts index 784eda2e7..616f7fc1f 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts @@ -31,55 +31,28 @@ * - `saveStates[decisionId]` - UI save indicator state */ import type { InstanceData, InstancePhaseData } from '@op/api/encoders'; -import type { ProposalCategory, ProposalTemplateSchema } from '@op/common'; +import type { ProposalTemplateSchema } from '@op/common'; import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; // ============ Store-specific Types ============ /** - * Extended instance data for form state. - * Includes fields stored separately in the DB but tracked together for form convenience. + * Editable instance data for the process builder. * - * Backend-aligned fields (from InstanceData): - * - budget, hideBudget, fieldValues, currentPhaseId, stateData, phases - * - * Form-only fields (not yet in backend, stored in localStorage only): - * - steward, objective, includeReview, isPrivate + * Mirrors the server shape: inherits `config`, `phases`, etc. from + * `InstanceData` and adds instance-column fields (`name`, `description`, + * `stewardProfileId`) that live outside the JSON blob. */ -export interface FormInstanceData +export interface ProcessBuilderInstanceData extends Omit, 'proposalTemplate'> { - /** Instance name (stored in processInstances.name, not instanceData) */ + // Instance columns (not in instanceData JSON) name?: string; - /** Instance description (stored in processInstances.description, not instanceData) */ description?: string; - - // Form-only fields (not in backend InstanceData yet) - // TODO: Add these to backend schema when ready to persist - /** Profile ID of the steward */ stewardProfileId?: string; - /** Process objective description */ - objective?: string; - /** Total budget available */ - budget?: number; - /** Whether to hide budget from participants */ - hideBudget?: boolean; - /** Whether to include proposal review phase */ - includeReview?: boolean; - /** Whether to keep process private */ - isPrivate?: boolean; - /** Whether to organize proposals into categories */ - organizeByCategories?: boolean; - /** Whether to require collaborative proposals */ - requireCollaborativeProposals?: boolean; - /** Proposal template (JSON Schema) */ + + // Override InstanceData's proposalTemplate with form-specific type proposalTemplate?: ProposalTemplateSchema; - /** Proposal categories */ - categories?: ProposalCategory[]; - /** Whether proposers must select at least one category */ - requireCategorySelection?: boolean; - /** Whether proposers can select more than one category */ - allowMultipleCategories?: boolean; } // ============ UI-only Types ============ @@ -95,16 +68,18 @@ interface SaveState { interface ProcessBuilderState { // Instance data keyed by decisionId - instances: Record; + instances: Record; // Save state keyed by decisionId saveStates: Record; // Actions for instance data setInstanceData: ( decisionId: string, - data: Partial, + data: Partial, ) => void; - getInstanceData: (decisionId: string) => FormInstanceData | undefined; + getInstanceData: ( + decisionId: string, + ) => ProcessBuilderInstanceData | undefined; // Actions for phase data (operates on phases array) setPhaseData: ( @@ -118,11 +93,11 @@ interface ProcessBuilderState { ) => InstancePhaseData | undefined; // Actions for proposal template - setProposalTemplate: ( + setProposalTemplateSchema: ( decisionId: string, template: ProposalTemplateSchema, ) => void; - getProposalTemplate: ( + getProposalTemplateSchema: ( decisionId: string, ) => ProposalTemplateSchema | undefined; @@ -146,15 +121,19 @@ export const useProcessBuilderStore = create()( // Instance data actions setInstanceData: (decisionId, data) => - set((state) => ({ - instances: { - ...state.instances, - [decisionId]: { - ...state.instances[decisionId], - ...data, + set((state) => { + const existing = state.instances[decisionId]; + return { + instances: { + ...state.instances, + [decisionId]: { + ...existing, + ...data, + config: { ...existing?.config, ...data.config }, + }, }, - }, - })), + }; + }), getInstanceData: (decisionId) => get().instances[decisionId], @@ -197,7 +176,7 @@ export const useProcessBuilderStore = create()( }, // Proposal template actions - setProposalTemplate: (decisionId, template) => + setProposalTemplateSchema: (decisionId, template) => set((state) => ({ instances: { ...state.instances, @@ -208,7 +187,7 @@ export const useProcessBuilderStore = create()( }, })), - getProposalTemplate: (decisionId) => + getProposalTemplateSchema: (decisionId) => get().instances[decisionId]?.proposalTemplate, // Save state actions diff --git a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts index 2e5d3bd07..6620d8a65 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { getFieldErrors, getFields } from '../../proposalTemplate'; import type { SectionId } from '../navigationConfig'; -import type { FormInstanceData } from '../stores/useProcessBuilderStore'; +import type { ProcessBuilderInstanceData } from '../stores/useProcessBuilderStore'; // ============ Types ============ @@ -37,9 +37,13 @@ const phasesSchema = z.object({ // ============ Section Validators ============ -type SectionValidator = (data: FormInstanceData | undefined) => boolean; +type SectionValidator = ( + data: ProcessBuilderInstanceData | undefined, +) => boolean; -function validateTemplateEditor(data: FormInstanceData | undefined): boolean { +function validateTemplateEditor( + data: ProcessBuilderInstanceData | undefined, +): boolean { if (!data?.proposalTemplate) { return false; } @@ -62,7 +66,7 @@ const SECTION_VALIDATORS: Record = { interface ChecklistItem { id: string; labelKey: string; - validate: (data: FormInstanceData | undefined) => boolean; + validate: (data: ProcessBuilderInstanceData | undefined) => boolean; } /** @@ -121,7 +125,7 @@ const LAUNCH_CHECKLIST: ChecklistItem[] = [ // ============ Validation ============ export function validateAll( - data: FormInstanceData | undefined, + data: ProcessBuilderInstanceData | undefined, ): ValidationSummary { const sections = {} as Record; for (const [sectionId, validator] of Object.entries(SECTION_VALIDATORS)) { diff --git a/apps/app/src/components/decisions/proposalTemplate.ts b/apps/app/src/components/decisions/proposalTemplate.ts index 832cfdab2..996eddc8c 100644 --- a/apps/app/src/components/decisions/proposalTemplate.ts +++ b/apps/app/src/components/decisions/proposalTemplate.ts @@ -1,7 +1,7 @@ /** * Proposal Template — JSON Schema utilities. * - * A ProposalTemplate is a plain JSON Schema. Field ordering is stored as a + * Uses `ProposalTemplateSchema` from `@op/common`. Field ordering is stored as a * top-level `x-field-order` array. Per-field widget selection is driven by `x-format` * on each property (consumed by the renderer's FORMAT_REGISTRY). * @@ -9,21 +9,19 @@ * via vendor extensions (`x-*` properties). */ import { - ProposalTemplateSchema, + type ProposalTemplateSchema, SYSTEM_FIELD_KEYS, buildCategorySchema, parseSchemaOptions, schemaHasOptions, } from '@op/common/client'; -// --------------------------------------------------------------------------- -// Core types -// --------------------------------------------------------------------------- +export type { ProposalTemplateSchema }; export type FieldType = 'short_text' | 'long_text' | 'dropdown'; /** - * Flat read-only view of a single field, derived from a ProposalTemplate. + * Flat read-only view of a single field, derived from a proposal template. * Gives builder/renderer code a friendly object instead of requiring * multiple reader calls per field. */ @@ -260,7 +258,7 @@ export function getFieldErrors(field: FieldView): string[] { } // --------------------------------------------------------------------------- -// Immutable mutators — each returns a new ProposalTemplate +// Immutable mutators — each returns a new template // --------------------------------------------------------------------------- export function addField( From 22d944e43b35d0b766c93509f860759178230ee1 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com> Date: Sat, 28 Feb 2026 08:00:02 +0100 Subject: [PATCH 03/14] Add rubric participant preview panel (#679) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Gives admins a live preview of how reviewers will experience the rubric form, reducing the guesswork when configuring scoring criteria - Lays the groundwork for the full rubric builder by adopting the same split-pane layout used in the proposal template editor (blank left panel for now, participant preview on the right) - Gated behind the `rubric_builder` PostHog feature flag — production users still see the "coming soon" placeholder until the builder is ready --- .../stepContent/rubric/CriteriaSection.tsx | 29 +++- .../rubric/RubricFormPreviewRenderer.tsx | 126 ++++++++++++++++++ .../rubric/RubricParticipantPreview.tsx | 48 +++++++ .../stepContent/rubric/dummyRubricTemplate.ts | 84 ++++++++++++ .../stepContent/template/FieldCard.tsx | 2 +- .../template/ParticipantPreview.tsx | 6 +- .../template/TemplateEditorContent.tsx | 8 +- .../stepContent/template/fieldRegistry.tsx | 2 +- .../decisions/ProposalContentRenderer.tsx | 10 +- .../src/components/decisions/ProposalView.tsx | 2 +- .../decisions/forms/FieldHeader.tsx | 38 ++++++ .../components/decisions/forms/proposal.ts | 44 ++++++ .../src/components/decisions/forms/rubric.ts | 45 +++++++ .../src/components/decisions/forms/types.ts | 16 +++ .../proposalEditor/ProposalEditor.tsx | 6 +- .../proposalEditor/ProposalFormRenderer.tsx | 36 +---- .../proposalEditor/compileProposalSchema.ts | 82 ------------ .../proposalEditor/useProposalValidation.ts | 3 +- apps/app/src/lib/i18n/dictionaries/bn.json | 6 +- apps/app/src/lib/i18n/dictionaries/en.json | 6 +- apps/app/src/lib/i18n/dictionaries/es.json | 6 +- apps/app/src/lib/i18n/dictionaries/fr.json | 6 +- apps/app/src/lib/i18n/dictionaries/pt.json | 6 +- .../e2e/tests/create-process-instance.spec.ts | 30 +++-- .../tests/proposal-submit-validation.spec.ts | 38 +++--- 25 files changed, 511 insertions(+), 174 deletions(-) create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts create mode 100644 apps/app/src/components/decisions/forms/FieldHeader.tsx create mode 100644 apps/app/src/components/decisions/forms/proposal.ts create mode 100644 apps/app/src/components/decisions/forms/rubric.ts create mode 100644 apps/app/src/components/decisions/forms/types.ts delete mode 100644 apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx index 5a6d0b51c..509c08ac0 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx @@ -1,10 +1,37 @@ +'use client'; + +import { useFeatureFlag } from '@/hooks/useFeatureFlag'; +import { Suspense } from 'react'; + import { useTranslations } from '@/lib/i18n'; import type { SectionProps } from '../../contentRegistry'; import { CodeAnimation } from './RubricComingSoonAnimation'; +import { RubricParticipantPreview } from './RubricParticipantPreview'; +import { DUMMY_RUBRIC_TEMPLATE } from './dummyRubricTemplate'; + +export default function CriteriaSection(props: SectionProps) { + return ( + + + + ); +} -export default function CriteriaSection(_props: SectionProps) { +function CriteriaSectionContent(_props: SectionProps) { const t = useTranslations(); + const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); + + if (rubricBuilderEnabled) { + return ( +
+ {/* Left panel — placeholder for the future rubric builder */} +
+ + +
+ ); + } return (
diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx new file mode 100644 index 000000000..b358027d1 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx @@ -0,0 +1,126 @@ +'use client'; + +import type { XFormatPropertySchema } from '@op/common/client'; +import { Select } from '@op/ui/Select'; +import { ToggleButton } from '@op/ui/ToggleButton'; + +import { useTranslations } from '@/lib/i18n'; + +import { FieldHeader } from '../../../forms/FieldHeader'; +import type { FieldDescriptor } from '../../../forms/types'; + +/** Yes/no field: `type: "string"` with exactly `"yes"` and `"no"` oneOf entries. */ +function isYesNoField(schema: XFormatPropertySchema): boolean { + if ( + schema.type !== 'string' || + !Array.isArray(schema.oneOf) || + schema.oneOf.length !== 2 + ) { + return false; + } + const values = schema.oneOf + .filter( + (e): e is { const: string } => + typeof e === 'object' && e !== null && 'const' in e, + ) + .map((e) => e.const); + return values.includes('yes') && values.includes('no'); +} + +/** Scored integer scale (e.g. 1-5 rating). */ +function isScoredField(schema: XFormatPropertySchema): boolean { + return schema.type === 'integer' && typeof schema.maximum === 'number'; +} + +/** Static placeholder for a single rubric criterion. */ +function RubricField({ field }: { field: FieldDescriptor }) { + const t = useTranslations(); + const { format, schema } = field; + + switch (format) { + case 'dropdown': { + if (isYesNoField(schema)) { + return ( +
+ +
+ + {schema.description && ( +

+ {schema.description} +

+ )} +
+
+ ); + } + + const badge = isScoredField(schema) + ? `${schema.maximum} ${t('pts')}` + : undefined; + + return ( +
+ + +
+ ); + } + + case 'short-text': + case 'long-text': { + return ( +
+ +
+ {t('Start typing...')} +
+
+ ); + } + + default: + return null; + } +} + +/** + * Static read-only preview of rubric fields. + * Shows field labels and placeholder inputs — no interactivity. + */ +export function RubricFormPreviewRenderer({ + fields, +}: { + fields: FieldDescriptor[]; +}) { + return ( +
+ {fields.map((field) => ( + + ))} +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx new file mode 100644 index 000000000..647d1725f --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx @@ -0,0 +1,48 @@ +'use client'; + +import type { RubricTemplateSchema } from '@op/common/client'; +import { useMemo } from 'react'; +import { LuEye } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; + +import { compileRubricSchema } from '../../../forms/rubric'; +import { RubricFormPreviewRenderer } from './RubricFormPreviewRenderer'; + +/** + * Live participant preview panel for rubric criteria. + * + * Mirrors the proposal `ParticipantPreview` pattern: compiles the rubric + * template into field descriptors and renders them via `RubricFormPreviewRenderer` + * in a static, non-interactive preview aside panel. + */ +export function RubricParticipantPreview({ + template, +}: { + template: RubricTemplateSchema; +}) { + const t = useTranslations(); + + const fields = useMemo(() => compileRubricSchema(template), [template]); + + if (fields.length === 0) { + return null; + } + + return ( + + ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts new file mode 100644 index 000000000..38a596478 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts @@ -0,0 +1,84 @@ +import type { RubricTemplateSchema } from '@op/common/client'; + +/** + * Dummy rubric template used while the rubric builder is under development. + * Exercises every supported field type so the participant preview is representative. + */ +export const DUMMY_RUBRIC_TEMPLATE: RubricTemplateSchema = { + type: 'object', + 'x-field-order': [ + 'innovation', + 'feasibility', + 'meetsEligibility', + 'focusArea', + 'strengthsSummary', + 'overallComments', + ], + properties: { + innovation: { + type: 'integer', + title: 'Innovation', + description: 'Rate the innovation level of the proposal.', + 'x-format': 'dropdown', + minimum: 1, + maximum: 5, + oneOf: [ + { const: 1, title: 'Poor' }, + { const: 2, title: 'Below Average' }, + { const: 3, title: 'Average' }, + { const: 4, title: 'Good' }, + { const: 5, title: 'Excellent' }, + ], + }, + feasibility: { + type: 'integer', + title: 'Feasibility', + description: 'Rate the feasibility of the proposal.', + 'x-format': 'dropdown', + minimum: 1, + maximum: 5, + oneOf: [ + { const: 1, title: 'Poor' }, + { const: 2, title: 'Below Average' }, + { const: 3, title: 'Average' }, + { const: 4, title: 'Good' }, + { const: 5, title: 'Excellent' }, + ], + }, + meetsEligibility: { + type: 'string', + title: 'Meets Eligibility', + description: 'Does the proposal meet eligibility requirements?', + 'x-format': 'dropdown', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }, + focusArea: { + type: 'string', + title: 'Focus Area', + description: 'Primary focus area of the proposal.', + 'x-format': 'dropdown', + oneOf: [ + { const: 'education', title: 'Education' }, + { const: 'health', title: 'Health' }, + { const: 'environment', title: 'Environment' }, + { const: 'infrastructure', title: 'Infrastructure' }, + ], + }, + strengthsSummary: { + type: 'string', + title: 'Key Strengths', + description: 'Summarize the key strengths briefly.', + 'x-format': 'short-text', + }, + overallComments: { + type: 'string', + title: 'Overall Comments', + description: 'Provide detailed feedback for the proposer.', + 'x-format': 'long-text', + }, + }, + required: ['innovation', 'feasibility', 'meetsEligibility'], +}; diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx index 5beb0adb1..92e044a92 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx @@ -1,5 +1,6 @@ 'use client'; +import type { XFormatPropertySchema } from '@op/common/client'; import { FieldConfigCard, FieldConfigCardDragPreview, @@ -10,7 +11,6 @@ import { useRef } from 'react'; import { useTranslations } from '@/lib/i18n'; -import type { XFormatPropertySchema } from '../../../proposalEditor/compileProposalSchema'; import type { FieldView } from '../../../proposalTemplate'; import { getFieldConfigComponent, diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx index 35e705663..0c6d68e63 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx @@ -1,16 +1,14 @@ 'use client'; +import type { ProposalTemplateSchema } from '@op/common/client'; import { FileDropZone } from '@op/ui/FileDropZone'; import { useMemo } from 'react'; import { LuEye } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; +import { compileProposalSchema } from '../../../forms/proposal'; import { ProposalFormRenderer } from '../../../proposalEditor/ProposalFormRenderer'; -import { - type ProposalTemplateSchema, - compileProposalSchema, -} from '../../../proposalEditor/compileProposalSchema'; import type { ProposalDraftFields } from '../../../proposalEditor/useProposalDraft'; const EMPTY_DRAFT: ProposalDraftFields = { diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx index 3f739c314..00a1e1a21 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx @@ -2,6 +2,10 @@ import { trpc } from '@op/api/client'; import { SYSTEM_FIELD_KEYS } from '@op/common/client'; +import type { + ProposalTemplateSchema, + XFormatPropertySchema, +} from '@op/common/client'; import { useDebouncedCallback, useMediaQuery } from '@op/hooks'; import { screens } from '@op/styles/constants'; import { FieldConfigCard } from '@op/ui/FieldConfigCard'; @@ -14,10 +18,6 @@ import { LuAlignLeft, LuChevronDown, LuHash } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; -import type { - ProposalTemplateSchema, - XFormatPropertySchema, -} from '../../../proposalEditor/compileProposalSchema'; import { type FieldType, type FieldView, diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx index b722d2f25..3bbf99949 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx @@ -1,8 +1,8 @@ +import type { XFormatPropertySchema } from '@op/common/client'; import type { ComponentType } from 'react'; import type { IconType } from 'react-icons'; import { LuAlignLeft, LuChevronDown, LuLetterText } from 'react-icons/lu'; -import type { XFormatPropertySchema } from '../../../proposalEditor/compileProposalSchema'; import type { FieldType, FieldView } from '../../../proposalTemplate'; import { FieldConfigDropdown } from './FieldConfigDropdown'; diff --git a/apps/app/src/components/decisions/ProposalContentRenderer.tsx b/apps/app/src/components/decisions/ProposalContentRenderer.tsx index b083dfe99..bd039c39e 100644 --- a/apps/app/src/components/decisions/ProposalContentRenderer.tsx +++ b/apps/app/src/components/decisions/ProposalContentRenderer.tsx @@ -1,14 +1,12 @@ 'use client'; +import type { ProposalTemplateSchema } from '@op/common/client'; import { viewerProseStyles } from '@op/ui/RichTextEditor'; import { useMemo } from 'react'; import { ProposalHtmlContent } from './ProposalHtmlContent'; -import { - type ProposalFieldDescriptor, - type ProposalTemplateSchema, - compileProposalSchema, -} from './proposalEditor/compileProposalSchema'; +import { compileProposalSchema } from './forms/proposal'; +import type { FieldDescriptor } from './forms/types'; interface ProposalContentRendererProps { /** The proposal template schema (from processSchema or instanceData). */ @@ -61,7 +59,7 @@ function ViewField({ field, html, }: { - field: ProposalFieldDescriptor; + field: FieldDescriptor; html: string | undefined; }) { const { schema } = field; diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index 12ad9e6ef..903c68c58 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -8,6 +8,7 @@ import type { RouterOutput } from '@op/api'; import { trpc } from '@op/api/client'; import { parseProposalData } from '@op/common/client'; import type { SupportedLocale } from '@op/common/client'; +import type { ProposalTemplateSchema } from '@op/common/client'; import { Avatar } from '@op/ui/Avatar'; import { Header1 } from '@op/ui/Header'; import { Link } from '@op/ui/Link'; @@ -29,7 +30,6 @@ import { ProposalContentRenderer } from './ProposalContentRenderer'; import { ProposalHtmlContent } from './ProposalHtmlContent'; import { ProposalViewLayout } from './ProposalViewLayout'; import { TranslateBanner } from './TranslateBanner'; -import type { ProposalTemplateSchema } from './proposalEditor/compileProposalSchema'; type Proposal = RouterOutput['decision']['getProposal']; diff --git a/apps/app/src/components/decisions/forms/FieldHeader.tsx b/apps/app/src/components/decisions/forms/FieldHeader.tsx new file mode 100644 index 000000000..f4ce7d382 --- /dev/null +++ b/apps/app/src/components/decisions/forms/FieldHeader.tsx @@ -0,0 +1,38 @@ +/** Renders title + description header for a form field. */ +export function FieldHeader({ + title, + description, + badge, + className = 'gap-2', +}: { + title?: string; + description?: string; + /** Optional trailing element shown inline with the title (e.g. "5 pts", "Yes/No"). */ + badge?: React.ReactNode; + className?: string; +}) { + if (!title && !description) { + return null; + } + + return ( +
+ {title && + (badge ? ( +
+ + {title} + + {badge} +
+ ) : ( + + {title} + + ))} + {description && ( +

{description}

+ )} +
+ ); +} diff --git a/apps/app/src/components/decisions/forms/proposal.ts b/apps/app/src/components/decisions/forms/proposal.ts new file mode 100644 index 000000000..8873f0320 --- /dev/null +++ b/apps/app/src/components/decisions/forms/proposal.ts @@ -0,0 +1,44 @@ +import { + type ProposalTemplateSchema, + SYSTEM_FIELD_KEYS, + type XFormat, + getProposalTemplateFieldOrder, +} from '@op/common/client'; + +import type { FieldDescriptor } from './types'; + +const REQUIRED_SYSTEM_FIELDS = new Set(['title']); +const DEFAULT_X_FORMAT: XFormat = 'short-text'; + +/** + * Compiles a proposal template into field descriptors for rendering. + * Resolves `x-format` on each property and tags system fields (title, category, budget). + */ +export function compileProposalSchema( + proposalTemplate: ProposalTemplateSchema, +): FieldDescriptor[] { + const templateProperties = proposalTemplate.properties ?? {}; + + for (const key of REQUIRED_SYSTEM_FIELDS) { + if (!templateProperties[key]) { + console.error(`[compileProposalSchema] Missing system field "${key}"`); + } + } + + const { all } = getProposalTemplateFieldOrder(proposalTemplate); + + return all.flatMap((key): FieldDescriptor[] => { + const propSchema = templateProperties[key]; + if (!propSchema) { + return []; + } + return [ + { + key, + format: propSchema['x-format'] ?? DEFAULT_X_FORMAT, + isSystem: SYSTEM_FIELD_KEYS.has(key), + schema: propSchema, + }, + ]; + }); +} diff --git a/apps/app/src/components/decisions/forms/rubric.ts b/apps/app/src/components/decisions/forms/rubric.ts new file mode 100644 index 000000000..46de64e3d --- /dev/null +++ b/apps/app/src/components/decisions/forms/rubric.ts @@ -0,0 +1,45 @@ +import type { + RubricTemplateSchema, + XFormat, + XFormatPropertySchema, +} from '@op/common/client'; + +import type { FieldDescriptor } from './types'; + +const DEFAULT_X_FORMAT: XFormat = 'short-text'; + +/** + * Compiles a rubric template schema into field descriptors for rendering. + * + * Similar to `compileProposalSchema` but without system-field handling — + * all rubric criteria are treated as dynamic fields. + */ +export function compileRubricSchema( + template: RubricTemplateSchema, +): FieldDescriptor[] { + const properties = template.properties ?? {}; + const propertyKeys = Object.keys(properties); + + if (propertyKeys.length === 0) { + return []; + } + + const fieldOrder = template['x-field-order'] ?? []; + const seen = new Set(); + const orderedKeys: string[] = []; + + for (const key of [...fieldOrder, ...propertyKeys]) { + if (!seen.has(key) && properties[key]) { + seen.add(key); + orderedKeys.push(key); + } + } + + return orderedKeys.map((key) => ({ + key, + format: + (properties[key] as XFormatPropertySchema)['x-format'] ?? + DEFAULT_X_FORMAT, + schema: properties[key] as XFormatPropertySchema, + })); +} diff --git a/apps/app/src/components/decisions/forms/types.ts b/apps/app/src/components/decisions/forms/types.ts new file mode 100644 index 000000000..520d88c77 --- /dev/null +++ b/apps/app/src/components/decisions/forms/types.ts @@ -0,0 +1,16 @@ +import type { XFormat, XFormatPropertySchema } from '@op/common/client'; + +/** + * A compiled field descriptor produced by a schema compiler. Describes a + * single field with everything needed to render it. + */ +export interface FieldDescriptor { + /** Property key in the schema (e.g. "title", "summary"). */ + key: string; + /** Resolved display format. */ + format: XFormat; + /** The raw property schema definition for this field. */ + schema: XFormatPropertySchema; + /** Whether this is a system field (title, category, budget). Only relevant for proposals. */ + isSystem?: boolean; +} diff --git a/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx b/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx index fcb448859..44c98fe87 100644 --- a/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx +++ b/apps/app/src/components/decisions/proposalEditor/ProposalEditor.tsx @@ -8,6 +8,7 @@ import { type proposalEncoder, } from '@op/api/encoders'; import { type ProposalDataInput, parseProposalData } from '@op/common/client'; +import type { ProposalTemplateSchema } from '@op/common/client'; import { toast } from '@op/ui/Toast'; import type { Editor } from '@tiptap/react'; import { useRouter } from 'next/navigation'; @@ -26,12 +27,9 @@ import { ProposalAttachments } from '../ProposalAttachments'; import { ProposalEditorLayout } from '../ProposalEditorLayout'; import { ProposalEditorSkeleton } from '../ProposalEditorSkeleton'; import { ProposalInfoModal } from '../ProposalInfoModal'; +import { compileProposalSchema } from '../forms/proposal'; import { schemaHasOptions } from '../proposalTemplate'; import { ProposalFormRenderer } from './ProposalFormRenderer'; -import { - type ProposalTemplateSchema, - compileProposalSchema, -} from './compileProposalSchema'; import { handleMutationError } from './handleMutationError'; import { useProposalDraft } from './useProposalDraft'; import { useProposalValidation } from './useProposalValidation'; diff --git a/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx b/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx index 7434c1aa7..ff1ad7858 100644 --- a/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx +++ b/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx @@ -13,7 +13,8 @@ import { CollaborativeTextField, CollaborativeTitleField, } from '../../collaboration'; -import type { ProposalFieldDescriptor } from './compileProposalSchema'; +import { FieldHeader } from '../forms/FieldHeader'; +import type { FieldDescriptor } from '../forms/types'; import type { ProposalDraftFields } from './useProposalDraft'; // --------------------------------------------------------------------------- @@ -22,7 +23,7 @@ import type { ProposalDraftFields } from './useProposalDraft'; interface ProposalFormRendererProps { /** Compiled field descriptors from `compileProposalSchema`. */ - fields: ProposalFieldDescriptor[]; + fields: FieldDescriptor[]; /** Current draft values for system fields. */ draft: ProposalDraftFields; /** Called when any system field value changes. */ @@ -45,7 +46,7 @@ interface ProposalFormRendererProps { * both `oneOf` and legacy `enum` formats. */ function extractOptions( - schema: ProposalFieldDescriptor['schema'], + schema: FieldDescriptor['schema'], ): { value: string; label: string }[] { return parseSchemaOptions(schema).map((opt) => ({ value: opt.value, @@ -53,31 +54,6 @@ function extractOptions( })); } -/** Renders title and description header for a dynamic field. */ -function FieldHeader({ - title, - description, -}: { - title?: string; - description?: string; -}) { - if (!title && !description) { - return null; - } - return ( -
- {title && ( - - {title} - - )} - {description && ( -

{description}

- )} -
- ); -} - // --------------------------------------------------------------------------- // Field renderer // --------------------------------------------------------------------------- @@ -88,7 +64,7 @@ function FieldHeader({ * markup) but without any Yjs/TipTap collaboration dependencies. */ function renderField( - field: ProposalFieldDescriptor, + field: FieldDescriptor, draft: ProposalDraftFields, onFieldChange: (key: string, value: unknown) => void, t: (key: string, params?: Record) => string, @@ -303,7 +279,7 @@ export function ProposalFormRenderer({ const budgetField = fields.find((f) => f.key === 'budget'); const dynamicFields = fields.filter((f) => !f.isSystem); - const render = (field: ProposalFieldDescriptor) => + const render = (field: FieldDescriptor) => renderField( field, draft, diff --git a/apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts b/apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts deleted file mode 100644 index 6a5325cc7..000000000 --- a/apps/app/src/components/decisions/proposalEditor/compileProposalSchema.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - type ProposalTemplateSchema, - SYSTEM_FIELD_KEYS, - type XFormat, - type XFormatPropertySchema, - getProposalTemplateFieldOrder, -} from '@op/common/client'; - -export type { XFormatPropertySchema, ProposalTemplateSchema, XFormat }; - -/** System fields that must always be present. Others are conditionally added. */ -const REQUIRED_SYSTEM_FIELDS = new Set(['title']); - -/** Default `x-format` when a dynamic field omits the extension. */ -const DEFAULT_X_FORMAT: XFormat = 'short-text'; - -// --------------------------------------------------------------------------- -// Compiled field descriptor -// --------------------------------------------------------------------------- - -/** - * A field descriptor produced by the schema compiler. Each entry describes - * a single field in the proposal form, with all the information needed to - * render the correct collaborative component. - */ -export interface ProposalFieldDescriptor { - /** Property key in the schema (e.g. "title", "summary"). */ - key: string; - /** Resolved display format. */ - format: XFormat; - /** Whether this is a system field (title, category, budget). */ - isSystem: boolean; - /** The raw property schema definition for this field. */ - schema: XFormatPropertySchema; -} - -// --------------------------------------------------------------------------- -// compileProposalSchema -// --------------------------------------------------------------------------- - -/** - * Compiles a proposal template into an array of field descriptors that the - * renderer can iterate over. - * - * The template is the single source of truth for data shape — property - * types, constraints (`minimum`, `maximum`, `minLength`, etc.), and - * `required` arrays are preserved as-is. - * - * This function resolves the `x-format` vendor extension on each property - * into a typed descriptor. The template is expected to include system fields - * (title, category, budget) — missing ones are logged as errors. - * - * @param proposalTemplate - Proposal template schema stored on processSchema. - */ -export function compileProposalSchema( - proposalTemplate: ProposalTemplateSchema, -): ProposalFieldDescriptor[] { - const templateProperties = proposalTemplate.properties ?? {}; - - for (const key of REQUIRED_SYSTEM_FIELDS) { - if (!templateProperties[key]) { - console.error(`[compileProposalSchema] Missing system field "${key}"`); - } - } - - const { all } = getProposalTemplateFieldOrder(proposalTemplate); - - return all - .map((key) => { - const propSchema = templateProperties[key]; - if (!propSchema) { - return null; - } - return { - key, - format: propSchema['x-format'] ?? DEFAULT_X_FORMAT, - isSystem: SYSTEM_FIELD_KEYS.has(key), - schema: propSchema, - }; - }) - .filter((d): d is ProposalFieldDescriptor => d !== null); -} diff --git a/apps/app/src/components/decisions/proposalEditor/useProposalValidation.ts b/apps/app/src/components/decisions/proposalEditor/useProposalValidation.ts index 13e8e139d..2ac41b9d6 100644 --- a/apps/app/src/components/decisions/proposalEditor/useProposalValidation.ts +++ b/apps/app/src/components/decisions/proposalEditor/useProposalValidation.ts @@ -4,12 +4,11 @@ import { getProposalFragmentNames, schemaValidator, } from '@op/common/client'; +import type { ProposalTemplateSchema } from '@op/common/client'; import { useCallback } from 'react'; import * as Y from 'yjs'; import type { Doc } from 'yjs'; -import type { ProposalTemplateSchema } from './compileProposalSchema'; - /** * Recursively extracts the text content from an XmlElement, * concatenating all nested XmlText children (ignoring markup). diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 3d98e69f1..578ca6eaf 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -870,5 +870,9 @@ "No {type} found.": "কোনো {type} পাওয়া যায়নি।", "Could not load search results": "অনুসন্ধানের ফলাফল লোড করা যায়নি", "Timeline not set": "সময়সীমা নির্ধারিত হয়নি", - "Section not found": "বিভাগ পাওয়া যায়নি" + "Section not found": "বিভাগ পাওয়া যায়নি", + "Participant Preview": "অংশগ্রহণকারীর পূর্বরূপ", + "Review Proposal": "প্রস্তাব পর্যালোচনা", + "pts": "পয়েন্ট", + "Yes/No": "হ্যাঁ/না" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index b23bc7816..5008c1c05 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -863,5 +863,9 @@ "No {type} found.": "No {type} found.", "Could not load search results": "Could not load search results", "Timeline not set": "Timeline not set", - "Section not found": "Section not found" + "Section not found": "Section not found", + "Participant Preview": "Participant Preview", + "Review Proposal": "Review Proposal", + "pts": "pts", + "Yes/No": "Yes/No" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 6656e4e3c..e1b4e0931 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -862,5 +862,9 @@ "No {type} found.": "No se encontraron {type}.", "Could not load search results": "No se pudieron cargar los resultados de búsqueda", "Timeline not set": "Cronograma no establecido", - "Section not found": "Sección no encontrada" + "Section not found": "Sección no encontrada", + "Participant Preview": "Vista previa del participante", + "Review Proposal": "Revisar propuesta", + "pts": "pts", + "Yes/No": "Sí/No" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 0579c5856..d8ac057f0 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -862,5 +862,9 @@ "No {type} found.": "Aucun {type} trouvé.", "Could not load search results": "Impossible de charger les résultats de recherche", "Timeline not set": "Calendrier non défini", - "Section not found": "Section introuvable" + "Section not found": "Section introuvable", + "Participant Preview": "Aperçu du participant", + "Review Proposal": "Évaluer la proposition", + "pts": "pts", + "Yes/No": "Oui/Non" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index e95f8204d..76f270bc7 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -858,5 +858,9 @@ "No {type} found.": "Nenhum {type} encontrado.", "Could not load search results": "Não foi possível carregar os resultados da pesquisa", "Timeline not set": "Cronograma não definido", - "Section not found": "Seção não encontrada" + "Section not found": "Seção não encontrada", + "Participant Preview": "Pré-visualização do participante", + "Review Proposal": "Avaliar proposta", + "pts": "pts", + "Yes/No": "Sim/Não" } diff --git a/tests/e2e/tests/create-process-instance.spec.ts b/tests/e2e/tests/create-process-instance.spec.ts index f5953563b..581edce80 100644 --- a/tests/e2e/tests/create-process-instance.spec.ts +++ b/tests/e2e/tests/create-process-instance.spec.ts @@ -8,7 +8,7 @@ function waitForAutoSave(page: import('@playwright/test').Page) { return page.waitForResponse( (resp) => resp.url().includes('decision.updateDecisionInstance') && resp.ok(), - { timeout: 10000 }, + { timeout: 12_000 }, ); } @@ -16,13 +16,15 @@ test.describe('Create Process Instance', () => { test('can create a decision process and reach launch-ready state', async ({ authenticatedPage, }) => { + test.setTimeout(144_000); + // 1. Navigate to the decisions create page (server-side creates a draft // instance from the seeded template and redirects to the editor) await authenticatedPage.goto('/en/decisions/create'); // 2. Wait for the process builder editor to load await expect(authenticatedPage.getByText('Process Overview')).toBeVisible({ - timeout: 30000, + timeout: 36_000, }); // ── Step 1: General – Overview ────────────────────────────────────── @@ -36,7 +38,7 @@ test.describe('Create Process Instance', () => { .getByRole('listbox') .getByRole('option') .first(); - await expect(stewardOption).toBeVisible({ timeout: 5000 }); + await expect(stewardOption).toBeVisible({ timeout: 6_000 }); await stewardOption.click(); // 4. Fill in the process name @@ -54,14 +56,14 @@ test.describe('Create Process Instance', () => { // 6. Navigate to the Phases section const phasesTab = authenticatedPage.getByRole('tab', { name: 'Phases' }); - await expect(phasesTab).toBeVisible({ timeout: 15000 }); + await expect(phasesTab).toBeVisible({ timeout: 18_000 }); await phasesTab.click(); await expect( authenticatedPage.getByText( 'Define the phases of your decision-making process', ), - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible({ timeout: 12_000 }); // 7. Fill each phase's required fields. // The seeded template has phases rendered as collapsed accordions. @@ -80,7 +82,7 @@ test.describe('Create Process Instance', () => { // Wait for the phase content to be visible const headlineField = phase.getByLabel('Headline'); - await expect(headlineField).toBeVisible({ timeout: 5000 }); + await expect(headlineField).toBeVisible({ timeout: 6_000 }); // Fill phase name await phase.getByLabel('Phase name').fill(`Phase ${i + 1}`); @@ -116,12 +118,12 @@ test.describe('Create Process Instance', () => { const categoriesTab = authenticatedPage.getByRole('tab', { name: 'Proposal Categories', }); - await expect(categoriesTab).toBeVisible({ timeout: 15000 }); + await expect(categoriesTab).toBeVisible({ timeout: 18_000 }); await categoriesTab.click(); await expect( authenticatedPage.getByText('Proposal Categories').first(), - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible({ timeout: 12_000 }); // 9. Create one category await authenticatedPage @@ -156,7 +158,7 @@ test.describe('Create Process Instance', () => { await expect( authenticatedPage.getByText('Proposal template').first(), - ).toBeVisible({ timeout: 10000 }); + ).toBeVisible({ timeout: 12_000 }); // 11. Enable the Budget field in the template const templateSaved = waitForAutoSave(authenticatedPage); @@ -166,18 +168,18 @@ test.describe('Create Process Instance', () => { // Verify the budget config expanded (Currency select should appear) await expect(authenticatedPage.getByLabel('Currency')).toBeVisible({ - timeout: 5000, + timeout: 6_000, }); // 12. Verify the participant preview shows the budget field // The preview renders an "Add budget" button when budget is enabled await expect( authenticatedPage.getByText('Participant Preview'), - ).toBeVisible({ timeout: 5000 }); + ).toBeVisible({ timeout: 6_000 }); await expect( authenticatedPage.getByRole('button', { name: 'Add budget' }), - ).toBeVisible({ timeout: 5000 }); + ).toBeVisible({ timeout: 6_000 }); await templateSaved; @@ -187,7 +189,7 @@ test.describe('Create Process Instance', () => { const launchButton = authenticatedPage.getByRole('button', { name: 'Launch Process', }); - await expect(launchButton).toBeVisible({ timeout: 15000 }); - await expect(launchButton).toBeEnabled({ timeout: 15000 }); + await expect(launchButton).toBeVisible({ timeout: 18_000 }); + await expect(launchButton).toBeEnabled({ timeout: 18_000 }); }); }); diff --git a/tests/e2e/tests/proposal-submit-validation.spec.ts b/tests/e2e/tests/proposal-submit-validation.spec.ts index 381fc8f5a..2cdf2ec67 100644 --- a/tests/e2e/tests/proposal-submit-validation.spec.ts +++ b/tests/e2e/tests/proposal-submit-validation.spec.ts @@ -122,7 +122,7 @@ test.describe('Proposal Submit Validation', () => { status: ProposalStatus.DRAFT, }); - await new Promise((resolve) => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 600)); // -- Navigate to editor --------------------------------------------------- @@ -134,7 +134,7 @@ test.describe('Proposal Submit Validation', () => { const submitButton = authenticatedPage.getByRole('button', { name: 'Submit Proposal', }); - await expect(submitButton).toBeVisible({ timeout: 30_000 }); + await expect(submitButton).toBeVisible({ timeout: 36_000 }); // Helper: dismiss any visible toasts before the next submit const dismissToasts = async () => { @@ -147,7 +147,7 @@ test.describe('Proposal Submit Validation', () => { } } // Wait for toast to animate out - await authenticatedPage.waitForTimeout(300); + await authenticatedPage.waitForTimeout(360); }; // ========================================================================= @@ -161,7 +161,7 @@ test.describe('Proposal Submit Validation', () => { .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }); - await expect(errorToast).toBeVisible({ timeout: 5_000 }); + await expect(errorToast).toBeVisible({ timeout: 6_000 }); await expect(errorToast).toContainText('Title'); await expect(errorToast).toContainText('Budget'); await expect(errorToast).toContainText('Category'); @@ -177,7 +177,7 @@ test.describe('Proposal Submit Validation', () => { await authenticatedPage.keyboard.type('My Test Proposal'); // Wait for debounced auto-save to fire - await authenticatedPage.waitForTimeout(2_000); + await authenticatedPage.waitForTimeout(2_400); await submitButton.click(); @@ -185,7 +185,7 @@ test.describe('Proposal Submit Validation', () => { .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }); - await expect(errorToast2).toBeVisible({ timeout: 5_000 }); + await expect(errorToast2).toBeVisible({ timeout: 6_000 }); await expect(errorToast2).not.toContainText('Title'); await expect(errorToast2).toContainText('Budget'); await expect(errorToast2).toContainText('Category'); @@ -202,11 +202,11 @@ test.describe('Proposal Submit Validation', () => { await addBudgetButton.click(); const budgetInput = authenticatedPage.getByPlaceholder('Enter amount'); - await expect(budgetInput).toBeVisible({ timeout: 5_000 }); + await expect(budgetInput).toBeVisible({ timeout: 6_000 }); await budgetInput.fill('5000'); await budgetInput.blur(); - await authenticatedPage.waitForTimeout(2_000); + await authenticatedPage.waitForTimeout(2_400); await submitButton.click(); @@ -214,7 +214,7 @@ test.describe('Proposal Submit Validation', () => { .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }); - await expect(errorToast3).toBeVisible({ timeout: 5_000 }); + await expect(errorToast3).toBeVisible({ timeout: 6_000 }); await expect(errorToast3).not.toContainText('Title'); await expect(errorToast3).not.toContainText('Budget'); await expect(errorToast3).toContainText('Category'); @@ -240,7 +240,7 @@ test.describe('Proposal Submit Validation', () => { .getByRole('option', { name: 'Infrastructure' }) .click(); - await authenticatedPage.waitForTimeout(2_000); + await authenticatedPage.waitForTimeout(2_400); await submitButton.click(); @@ -251,7 +251,7 @@ test.describe('Proposal Submit Validation', () => { .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }); - await expect(errorToast4).toBeVisible({ timeout: 5_000 }); + await expect(errorToast4).toBeVisible({ timeout: 6_000 }); await expect(errorToast4).toContainText('Summary'); await expect(errorToast4).toContainText('Details'); await expect(errorToast4).toContainText('Priority Level'); @@ -275,7 +275,7 @@ test.describe('Proposal Submit Validation', () => { await summaryEditor.click(); await authenticatedPage.keyboard.type('A brief summary of the proposal'); - await authenticatedPage.waitForTimeout(2_000); + await authenticatedPage.waitForTimeout(2_400); await submitButton.click(); @@ -283,7 +283,7 @@ test.describe('Proposal Submit Validation', () => { .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }); - await expect(errorToast5).toBeVisible({ timeout: 5_000 }); + await expect(errorToast5).toBeVisible({ timeout: 6_000 }); await expect(errorToast5).not.toContainText('Summary'); await expect(errorToast5).toContainText('Details'); await expect(errorToast5).toContainText('Priority Level'); @@ -307,7 +307,7 @@ test.describe('Proposal Submit Validation', () => { 'Full details and justification for this proposal', ); - await authenticatedPage.waitForTimeout(2_000); + await authenticatedPage.waitForTimeout(2_400); await submitButton.click(); @@ -315,7 +315,7 @@ test.describe('Proposal Submit Validation', () => { .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }); - await expect(errorToast6).toBeVisible({ timeout: 5_000 }); + await expect(errorToast6).toBeVisible({ timeout: 6_000 }); await expect(errorToast6).not.toContainText('Summary'); await expect(errorToast6).not.toContainText('Details'); await expect(errorToast6).toContainText('Priority Level'); @@ -336,7 +336,7 @@ test.describe('Proposal Submit Validation', () => { await priorityField.getByRole('button', { name: 'Select option' }).click(); await authenticatedPage.getByRole('option', { name: 'High' }).click(); - await authenticatedPage.waitForTimeout(2_000); + await authenticatedPage.waitForTimeout(2_400); await submitButton.click(); @@ -344,7 +344,7 @@ test.describe('Proposal Submit Validation', () => { .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }); - await expect(errorToast7).toBeVisible({ timeout: 5_000 }); + await expect(errorToast7).toBeVisible({ timeout: 6_000 }); await expect(errorToast7).not.toContainText('Priority Level'); await expect(errorToast7).toContainText('Region'); @@ -359,7 +359,7 @@ test.describe('Proposal Submit Validation', () => { await regionField.getByRole('button', { name: 'Select option' }).click(); await authenticatedPage.getByRole('option', { name: 'North' }).click(); - await authenticatedPage.waitForTimeout(2_000); + await authenticatedPage.waitForTimeout(2_400); await submitButton.click(); @@ -371,6 +371,6 @@ test.describe('Proposal Submit Validation', () => { authenticatedPage .locator('[data-sonner-toast]') .filter({ hasText: 'Please complete the following required fields:' }), - ).not.toBeVisible({ timeout: 5_000 }); + ).not.toBeVisible({ timeout: 6_000 }); }); }); From f2b1947e9e782880853d30a1c202088e0145d600 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com> Date: Sat, 28 Feb 2026 08:24:36 +0100 Subject: [PATCH 04/14] Add per-criterion rationale fields to rubric schema (#686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds per-criterion rationale fields so reviewers can provide written reasoning alongside their scores — matching the "Require reasoning for scores" toggle in the rubric editor designs. No backend changes needed — rationale fields are regular JSON Schema properties validated by AJV like everything else. ## Demo CleanShot 2026-02-26 at 20 32 34 --- .../rubric/RubricFormPreviewRenderer.tsx | 28 +++++++++++++++ .../stepContent/rubric/dummyRubricTemplate.ts | 20 ++++++++++- packages/common/src/client.ts | 1 + .../decision/getRubricScoringInfo.test.ts | 34 +++++++++++++++++-- .../services/decision/getRubricScoringInfo.ts | 12 +++++++ 5 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx index b358027d1..58b1185fc 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx @@ -1,6 +1,7 @@ 'use client'; import type { XFormatPropertySchema } from '@op/common/client'; +import { isRationaleField } from '@op/common/client'; import { Select } from '@op/ui/Select'; import { ToggleButton } from '@op/ui/ToggleButton'; @@ -32,11 +33,38 @@ function isScoredField(schema: XFormatPropertySchema): boolean { return schema.type === 'integer' && typeof schema.maximum === 'number'; } +/** Compact rationale textarea rendered inline under a parent criterion. */ +function RationaleField({ field }: { field: FieldDescriptor }) { + const t = useTranslations(); + const { schema } = field; + const isRequired = true; // rationale fields are required when present in schema + + return ( +
+ + {schema.title ?? t('Reason(s) and Insight(s)')} + {isRequired && ( + + )} + +
+ {t('Placeholder')} +
+
+ ); +} + /** Static placeholder for a single rubric criterion. */ function RubricField({ field }: { field: FieldDescriptor }) { const t = useTranslations(); const { format, schema } = field; + if (isRationaleField(field.key)) { + return ; + } + switch (format) { case 'dropdown': { if (isYesNoField(schema)) { diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts index 38a596478..a5dfe217d 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts @@ -8,7 +8,9 @@ export const DUMMY_RUBRIC_TEMPLATE: RubricTemplateSchema = { type: 'object', 'x-field-order': [ 'innovation', + 'innovation__rationale', 'feasibility', + 'feasibility__rationale', 'meetsEligibility', 'focusArea', 'strengthsSummary', @@ -30,6 +32,11 @@ export const DUMMY_RUBRIC_TEMPLATE: RubricTemplateSchema = { { const: 5, title: 'Excellent' }, ], }, + innovation__rationale: { + type: 'string', + title: 'Reason(s) and Insight(s)', + 'x-format': 'long-text', + }, feasibility: { type: 'integer', title: 'Feasibility', @@ -45,6 +52,11 @@ export const DUMMY_RUBRIC_TEMPLATE: RubricTemplateSchema = { { const: 5, title: 'Excellent' }, ], }, + feasibility__rationale: { + type: 'string', + title: 'Reason(s) and Insight(s)', + 'x-format': 'long-text', + }, meetsEligibility: { type: 'string', title: 'Meets Eligibility', @@ -80,5 +92,11 @@ export const DUMMY_RUBRIC_TEMPLATE: RubricTemplateSchema = { 'x-format': 'long-text', }, }, - required: ['innovation', 'feasibility', 'meetsEligibility'], + required: [ + 'innovation', + 'innovation__rationale', + 'feasibility', + 'feasibility__rationale', + 'meetsEligibility', + ], }; diff --git a/packages/common/src/client.ts b/packages/common/src/client.ts index e6509819a..449d74a82 100644 --- a/packages/common/src/client.ts +++ b/packages/common/src/client.ts @@ -17,6 +17,7 @@ export { type SchemaValidationResult, } from './services/decision/schemaValidator'; export { serverExtensions } from './services/decision/tiptapExtensions'; +export { isRationaleField } from './services/decision/getRubricScoringInfo'; // Translation constants (no server dependencies) export { diff --git a/packages/common/src/services/decision/getRubricScoringInfo.test.ts b/packages/common/src/services/decision/getRubricScoringInfo.test.ts index 2f7d5bddc..d7a762873 100644 --- a/packages/common/src/services/decision/getRubricScoringInfo.test.ts +++ b/packages/common/src/services/decision/getRubricScoringInfo.test.ts @@ -6,19 +6,22 @@ import type { RubricTemplateSchema } from './types'; /** * SEI-style rubric template fixture. * - * 6 criteria total: + * 6 criteria + 2 rationale companion fields: * - 2 scored (integer, dropdown): innovation (max 5), feasibility (max 5) + * - 2 rationale (long-text): innovation__rationale, feasibility__rationale * - 1 yes/no (dropdown, string) * - 1 multiple-choice (dropdown, string) * - 2 text fields (short-text + long-text) * - * Expected: totalPoints = 10, 2 scored criteria. + * Expected: totalPoints = 10, 2 scored criteria. Rationale fields excluded. */ const seiRubricTemplate = { type: 'object', 'x-field-order': [ 'innovation', + 'innovation__rationale', 'feasibility', + 'feasibility__rationale', 'meetsEligibility', 'focusArea', 'strengthsSummary', @@ -40,6 +43,11 @@ const seiRubricTemplate = { { const: 5, title: 'Excellent' }, ], }, + innovation__rationale: { + type: 'string', + title: 'Reason(s) and Insight(s)', + 'x-format': 'long-text', + }, feasibility: { type: 'integer', title: 'Feasibility', @@ -55,6 +63,11 @@ const seiRubricTemplate = { { const: 5, title: 'Excellent' }, ], }, + feasibility__rationale: { + type: 'string', + title: 'Reason(s) and Insight(s)', + 'x-format': 'long-text', + }, meetsEligibility: { type: 'string', title: 'Meets Eligibility', @@ -90,7 +103,13 @@ const seiRubricTemplate = { 'x-format': 'long-text', }, }, - required: ['innovation', 'feasibility', 'meetsEligibility'], + required: [ + 'innovation', + 'innovation__rationale', + 'feasibility', + 'feasibility__rationale', + 'meetsEligibility', + ], } as const satisfies RubricTemplateSchema; describe('getRubricScoringInfo', () => { @@ -106,9 +125,18 @@ describe('getRubricScoringInfo', () => { expect(scored.map((c) => c.maxPoints)).toEqual([5, 5]); }); + it('excludes __rationale companion fields from criteria list', () => { + const info = getRubricScoringInfo(seiRubricTemplate); + const keys = info.criteria.map((c) => c.key); + + expect(keys).not.toContain('innovation__rationale'); + expect(keys).not.toContain('feasibility__rationale'); + }); + it('produces correct summary counts keyed by x-format', () => { const info = getRubricScoringInfo(seiRubricTemplate); + // Rationale fields (long-text) are excluded from summary counts expect(info.summary).toEqual({ dropdown: 4, 'short-text': 1, diff --git a/packages/common/src/services/decision/getRubricScoringInfo.ts b/packages/common/src/services/decision/getRubricScoringInfo.ts index a445998f5..975508dca 100644 --- a/packages/common/src/services/decision/getRubricScoringInfo.ts +++ b/packages/common/src/services/decision/getRubricScoringInfo.ts @@ -1,5 +1,15 @@ import type { RubricTemplateSchema, XFormat } from './types'; +/** + * `__rationale` companion fields are system-managed long-text fields + * that capture per-criterion reasoning. They follow the naming convention + * `__rationale` and should be excluded from scoring, + * criteria counts, and rendered inline under their parent criterion. + */ +export function isRationaleField(key: string): boolean { + return key.endsWith('__rationale'); +} + /** Scoring info for a single rubric criterion. */ export interface RubricCriterion { key: string; @@ -37,6 +47,8 @@ export function getRubricScoringInfo( let totalPoints = 0; for (const [key, prop] of Object.entries(properties)) { + if (isRationaleField(key)) continue; + const scored = prop.type === 'integer'; const maxPoints = scored ? typeof prop.maximum === 'number' From 5b2c4a48294064c341130d1b628b34c143286133 Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Mon, 2 Mar 2026 15:53:44 +0100 Subject: [PATCH 05/14] Typed Translations (#683) This ensures that all translation keys are properly typed against the dictionary. It makes sure that we never have translations keys that don't exist in the dictionary with translations. Typecheck now flags these situations and your IDE should also be showing it as an error. ### Example with a bad translation key Screenshot 2026-02-26 at 13 08 04 --- .claude/commands/review-branch.md | 7 +- apps/app/global.d.ts | 17 ++ .../DeleteOrganizationModal/index.tsx | 2 +- .../src/components/InviteUserModal/index.tsx | 3 +- .../Onboarding/FundingInformationForm.tsx | 10 +- .../Onboarding/MatchingOrganizationsForm.tsx | 2 +- .../Onboarding/PersonalDetailsForm.tsx | 7 +- apps/app/src/components/Onboarding/index.tsx | 5 +- .../shared/OrganizationFormFields.tsx | 4 +- .../shared/organizationValidation.ts | 9 +- .../OrganizationsSearchResults/index.tsx | 38 +++-- .../components/PendingRelationships/index.tsx | 27 ++-- apps/app/src/components/PostUpdate/index.tsx | 8 +- .../ProfileDetails/CreateOrganizationForm.tsx | 5 +- .../ProfileDetails/UpdateOrganizationForm.tsx | 5 +- .../src/components/TranslatedText/index.tsx | 6 +- .../decisions/DecisionCardHeader.tsx | 4 +- .../components/decisions/DecisionListItem.tsx | 3 +- .../ProcessBuilder/navigationConfig.ts | 3 +- .../general/OverviewSectionForm.tsx | 3 +- .../stepContent/template/FieldCard.tsx | 3 +- .../stepContent/template/fieldRegistry.tsx | 12 +- .../validation/processBuilderValidation.ts | 6 +- .../proposalEditor/ProposalFormRenderer.tsx | 5 +- .../proposalEditor/handleMutationError.ts | 4 +- .../screens/ComingSoon/ComingSoonScreen.tsx | 30 ++-- .../screens/LandingScreen/index.tsx | 2 +- apps/app/src/lib/i18n/dictionaries/bn.json | 152 ++++++++++-------- apps/app/src/lib/i18n/dictionaries/en.json | 92 ++++++----- apps/app/src/lib/i18n/dictionaries/es.json | 111 +++++++------ apps/app/src/lib/i18n/dictionaries/fr.json | 117 ++++++++------ apps/app/src/lib/i18n/dictionaries/pt.json | 111 +++++++------ apps/app/src/lib/i18n/routing.tsx | 93 +++++++++-- 33 files changed, 530 insertions(+), 376 deletions(-) create mode 100644 apps/app/global.d.ts diff --git a/.claude/commands/review-branch.md b/.claude/commands/review-branch.md index 39ce009a3..adea96021 100644 --- a/.claude/commands/review-branch.md +++ b/.claude/commands/review-branch.md @@ -1,11 +1,8 @@ --- description: Do a thorough code review of the current branch (or a GitHub PR) -argument-hint: [optional: github-pr-url] allowed-tools: Task, Bash, Gh --- -Do a thorough code review of this branch. If an argument is passed and it is a github pull request, use `gh pr` to retrieve the pull request and review the pull request. -If there is no argument, you should review the current changes on this branch (you can diff against the dev branch). +run /pr-review-toolkit:review-pr all parallel +(note that our branches are never based on main. Usually dev or a stacked PR) Always do this in planning mode and present the review at the end. - -Arguments: $ARGUMENTS diff --git a/apps/app/global.d.ts b/apps/app/global.d.ts new file mode 100644 index 000000000..d2d50349a --- /dev/null +++ b/apps/app/global.d.ts @@ -0,0 +1,17 @@ +/** + * Module augmentation for next-intl. + * Wires the English dictionary as the canonical message type so that + * next-intl's internal type resolution (useTranslations, getTranslations) + * is aware of all valid keys. Our custom TranslateFn in routing.tsx is the + * client-facing contract; this augmentation supports next-intl internals. + * See: https://next-intl.dev/docs/workflows/typescript + */ +import type messages from './src/lib/i18n/dictionaries/en.json'; + +type Messages = typeof messages; + +declare module 'next-intl' { + interface AppConfig { + Messages: Messages; + } +} diff --git a/apps/app/src/components/DeleteOrganizationModal/index.tsx b/apps/app/src/components/DeleteOrganizationModal/index.tsx index bab44e903..abf6093f3 100644 --- a/apps/app/src/components/DeleteOrganizationModal/index.tsx +++ b/apps/app/src/components/DeleteOrganizationModal/index.tsx @@ -161,7 +161,7 @@ const SelectProfileStep = ({

{t( - 'Please select the account you’d like to delete. This action cannot be undone.', + "Please select the account you'd like to delete. This action cannot be undone.", )}

string) => +const createFundingValidator = (t: TranslateFn) => z.object({ isReceivingFunds: z.boolean().prefault(false).optional(), isOfferingFunds: z.boolean().prefault(false).optional(), @@ -42,17 +43,18 @@ const createFundingValidator = (t: (key: string) => string) => }), }); -// Static validator for type inference and external schema composition +// Static validator for type inference and external schema composition. +// Must mirror createFundingValidator's structure (without translated error messages). export const validator = z.object({ isReceivingFunds: z.boolean().prefault(false).optional(), isOfferingFunds: z.boolean().prefault(false).optional(), acceptingApplications: z.boolean().prefault(false).optional(), receivingFundsDescription: z.string().max(200).optional(), receivingFundsTerms: z.array(multiSelectOptionValidator).optional(), - receivingFundsLink: z.string().optional(), + receivingFundsLink: zodUrl({ error: 'Enter a valid website address' }), offeringFundsTerms: z.array(multiSelectOptionValidator).optional(), offeringFundsDescription: z.string().max(200).optional(), - offeringFundsLink: z.string().optional(), + offeringFundsLink: zodUrl({ error: 'Enter a valid website address' }), }); export const FundingInformationForm = ({ diff --git a/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx b/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx index 86eab291f..c3bda2a65 100644 --- a/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx +++ b/apps/app/src/components/Onboarding/MatchingOrganizationsForm.tsx @@ -173,7 +173,7 @@ export const MatchingOrganizationsForm = ({
{t('Confirm Administrator Access')}
{t( - "For now, we're only supporting administrator accounts. In the future, we’ll be able to support member accounts.", + "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.", )}
diff --git a/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx b/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx index 936fc9507..e0a762853 100644 --- a/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx +++ b/apps/app/src/components/Onboarding/PersonalDetailsForm.tsx @@ -12,6 +12,7 @@ import { ReactNode, Suspense, useState } from 'react'; import { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; +import type { TranslateFn } from '@/lib/i18n'; import { StepProps } from '../MultiStepForm'; import { FocusAreasField } from '../Profile/ProfileDetails/FocusAreasField'; @@ -22,7 +23,7 @@ import { useOnboardingFormStore } from './useOnboardingFormStore'; type FormFields = z.infer; -export const createValidator = (t: (key: string) => string) => +export const createValidator = (t: TranslateFn) => z .object({ fullName: z @@ -163,8 +164,8 @@ export const PersonalDetailsForm = ({ if (file.size > DEFAULT_MAX_SIZE) { const maxSizeMB = (DEFAULT_MAX_SIZE / 1024 / 1024).toFixed(2); toast.error({ - message: t('File too large. Maximum size: {maxSizeMB}MB', { - maxSizeMB, + message: t('File too large. Maximum size: {size}MB', { + size: maxSizeMB, }), }); return; diff --git a/apps/app/src/components/Onboarding/index.tsx b/apps/app/src/components/Onboarding/index.tsx index d22e4934f..f2ed5e385 100644 --- a/apps/app/src/components/Onboarding/index.tsx +++ b/apps/app/src/components/Onboarding/index.tsx @@ -153,10 +153,7 @@ export const OnboardingFlow = () => { if (errorInfo.isConnectionError) { toast.error({ title: t('Connection issue'), - message: - errorInfo.message + - ' ' + - t('Please try submitting the form again.'), + message: t('Please try submitting the form again.'), }); } else { toast.error({ diff --git a/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx b/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx index 766eae1cc..9c458c53b 100644 --- a/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx +++ b/apps/app/src/components/Onboarding/shared/OrganizationFormFields.tsx @@ -105,8 +105,8 @@ export const OrganizationFormFields = ({ if (file.size > DEFAULT_MAX_SIZE) { const maxSizeMB = (DEFAULT_MAX_SIZE / 1024 / 1024).toFixed(2); toast.error({ - message: t('File too large. Maximum size: {maxSizeMB}MB', { - maxSizeMB, + message: t('File too large. Maximum size: {size}MB', { + size: maxSizeMB, }), }); return; diff --git a/apps/app/src/components/Onboarding/shared/organizationValidation.ts b/apps/app/src/components/Onboarding/shared/organizationValidation.ts index 463468d22..877289d27 100644 --- a/apps/app/src/components/Onboarding/shared/organizationValidation.ts +++ b/apps/app/src/components/Onboarding/shared/organizationValidation.ts @@ -1,6 +1,8 @@ import { zodUrl } from '@op/common/validation'; import { z } from 'zod'; +import type { TranslateFn } from '@/lib/i18n'; + export const multiSelectOptionValidator = z.object({ id: z.string(), label: z.string().max(200), @@ -8,7 +10,7 @@ export const multiSelectOptionValidator = z.object({ data: z.record(z.string(), z.any()).prefault({}), }); -export const createOrganizationFormValidator = (t: (key: string) => string) => +export const createOrganizationFormValidator = (t: TranslateFn) => z.object({ name: z .string({ @@ -66,10 +68,11 @@ export const createOrganizationFormValidator = (t: (key: string) => string) => orgBannerImageId: z.string().optional(), }); -// Static validator for type inference and external schema composition +// Static validator for type inference and external schema composition. +// Must mirror createOrganizationFormValidator's structure (without translated error messages). export const organizationFormValidator = z.object({ name: z.string().min(1).max(100), - website: z.string().optional(), + website: zodUrl({ isRequired: true, error: 'Enter a valid website address' }), email: z.email().max(200), orgType: z.string().max(200).min(1), bio: z.string().max(150).min(1), diff --git a/apps/app/src/components/OrganizationsSearchResults/index.tsx b/apps/app/src/components/OrganizationsSearchResults/index.tsx index 72d6484ce..d18a8e8ee 100644 --- a/apps/app/src/components/OrganizationsSearchResults/index.tsx +++ b/apps/app/src/components/OrganizationsSearchResults/index.tsx @@ -40,8 +40,14 @@ export const ProfileSearchResultsSuspense = ({ return totalResults > 0 ? ( <> - {t('Results for')}{' '} - {query} + + {t.rich('Results for {query}', { + query: query, + highlight: (chunks: React.ReactNode) => ( + {chunks} + ), + })} + {individualSearchEnabled ? ( @@ -58,8 +64,14 @@ export const ProfileSearchResultsSuspense = ({ ) : ( <> - {t('No results for')}{' '} - {query} + + {t.rich('No results for {query}', { + query: query, + highlight: (chunks: React.ReactNode) => ( + {chunks} + ), + })} +
@@ -88,22 +100,22 @@ export const TabbedProfileSearchResults = ({ {profiles.map(({ type, results }) => { - const typeName = match(type, { - [EntityType.INDIVIDUAL]: 'Individual', - [EntityType.ORG]: 'Organization', + const label = match(type, { + [EntityType.INDIVIDUAL]: t('Individuals'), + [EntityType.ORG]: t('Organizations'), }); return ( - {t(typeName)}s + {label} {results.length} ); })} {profiles.map(({ type, results }) => { - const typeName = match(type, { - [EntityType.INDIVIDUAL]: 'Individual', - [EntityType.ORG]: 'Organization', + const label = match(type, { + [EntityType.INDIVIDUAL]: t('individuals'), + [EntityType.ORG]: t('organizations'), }); return ( @@ -111,9 +123,7 @@ export const TabbedProfileSearchResults = ({ ) : (
- {t('No {type} found.', { - type: t(typeName).toLocaleLowerCase() + 's', - })} + {t('No {type} found.', { type: label })}
)}
diff --git a/apps/app/src/components/PendingRelationships/index.tsx b/apps/app/src/components/PendingRelationships/index.tsx index 254bef1fb..fb4857699 100644 --- a/apps/app/src/components/PendingRelationships/index.tsx +++ b/apps/app/src/components/PendingRelationships/index.tsx @@ -90,23 +90,24 @@ const PendingRelationshipsSuspense = ({ slug }: { slug: string }) => { {org.profile.name} {isAccepted ? ( - <> - - {' '} - {t('will now appear as a')} - {' '} - {relationships ?? t('related organization')}{' '} - - {' '} - {t('on your profile.')} - - + + {' '} + {t( + 'will now appear as a {relationship} on your profile.', + { + relationship: + relationships ?? t('related organization'), + }, + )} + ) : null} {!isAccepted ? ( - {t('Added you as a')}{' '} - {relationships ?? t('related organization')} + {t('Added you as a {relationship}', { + relationship: + relationships ?? t('related organization'), + })} ) : null}
diff --git a/apps/app/src/components/PostUpdate/index.tsx b/apps/app/src/components/PostUpdate/index.tsx index 43a73aee3..f10a76ed5 100644 --- a/apps/app/src/components/PostUpdate/index.tsx +++ b/apps/app/src/components/PostUpdate/index.tsx @@ -17,7 +17,7 @@ import { toast } from '@op/ui/Toast'; import { cn } from '@op/ui/utils'; import { useRouter } from 'next/navigation'; import { useEffect, useRef, useState } from 'react'; -import type { RefObject } from 'react'; +import type { ReactNode, RefObject } from 'react'; import { LuImage, LuX } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -64,7 +64,7 @@ const PostUpdateWithUser = ({ profileId?: string; // Profile ID to associate the post with (can be any profile type) placeholder?: string; onSuccess?: () => void; - label: string; + label: ReactNode; proposalId?: string; // If provided, this is a proposal comment processInstanceId?: string; // Process instance ID for proposal comments characterLimit?: number; @@ -735,7 +735,7 @@ const PostUpdateWithUser = ({ {createPost.isPending || createOrganizationPost.isPending ? ( ) : ( - t(label) + label )}
@@ -763,7 +763,7 @@ export const PostUpdate = ({ profileId?: string; placeholder?: string; onSuccess?: () => void; - label: string; + label: ReactNode; proposalId?: string; processInstanceId?: string; }) => { diff --git a/apps/app/src/components/Profile/ProfileDetails/CreateOrganizationForm.tsx b/apps/app/src/components/Profile/ProfileDetails/CreateOrganizationForm.tsx index fe8a2c201..2b6e0d86c 100644 --- a/apps/app/src/components/Profile/ProfileDetails/CreateOrganizationForm.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/CreateOrganizationForm.tsx @@ -98,10 +98,7 @@ export const CreateOrganizationForm = forwardRef< if (errorInfo.isConnectionError) { toast.error({ title: t('Connection issue'), - message: - errorInfo.message + - ' ' + - t('Please try submitting the form again.'), + message: t('Please try submitting the form again.'), }); } else { toast.error({ diff --git a/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx b/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx index 0db8d05c0..5267c380d 100644 --- a/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx +++ b/apps/app/src/components/Profile/ProfileDetails/UpdateOrganizationForm.tsx @@ -175,10 +175,7 @@ export const UpdateOrganizationForm = forwardRef< if (errorInfo.isConnectionError) { toast.error({ title: t('Connection issue'), - message: - errorInfo.message + - ' ' + - t('Please try submitting the form again.'), + message: t('Please try submitting the form again.'), }); } else { toast.error({ diff --git a/apps/app/src/components/TranslatedText/index.tsx b/apps/app/src/components/TranslatedText/index.tsx index 6a710c169..5b9aed65c 100644 --- a/apps/app/src/components/TranslatedText/index.tsx +++ b/apps/app/src/components/TranslatedText/index.tsx @@ -2,11 +2,13 @@ /* * Wraps text in the client side translation hook for SERVER components that need translated text. - * Why: We need to shift to getTranslations but it doesn't yet support our dictionaries so we wrap this here so we can easily shift it out when it does. + * Why: Server-side getTranslations does not apply our custom dot-to-underscore key + * transformation, so we use this client component wrapper instead. */ import { useTranslations } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n'; -export const TranslatedText = ({ text }: { text: string }) => { +export const TranslatedText = ({ text }: { text: TranslationKey }) => { const t = useTranslations(); return t(text); }; diff --git a/apps/app/src/components/decisions/DecisionCardHeader.tsx b/apps/app/src/components/decisions/DecisionCardHeader.tsx index 2974d199a..e8d556984 100644 --- a/apps/app/src/components/decisions/DecisionCardHeader.tsx +++ b/apps/app/src/components/decisions/DecisionCardHeader.tsx @@ -5,6 +5,8 @@ import { Header3 } from '@op/ui/Header'; import { cn } from '@op/ui/utils'; import Image from 'next/image'; +import type { TranslationKey } from '@/lib/i18n'; + import { TranslatedText } from '../TranslatedText'; export const DecisionCardHeader = ({ @@ -35,7 +37,7 @@ export const DecisionCardHeader = ({ chipClassName ?? 'bg-primary-tealWhite text-primary-tealBlack' } > - + ) : null}
diff --git a/apps/app/src/components/decisions/DecisionListItem.tsx b/apps/app/src/components/decisions/DecisionListItem.tsx index 4e2c66f76..999f92ba8 100644 --- a/apps/app/src/components/decisions/DecisionListItem.tsx +++ b/apps/app/src/components/decisions/DecisionListItem.tsx @@ -13,6 +13,7 @@ import { LuCalendar } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; import { Link } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n'; import { TranslatedText } from '../TranslatedText'; import { DecisionCardHeader } from './DecisionCardHeader'; @@ -267,7 +268,7 @@ const DecisionStat = ({ className, }: { number: number; - label: string; + label: TranslationKey; className?: string; }) => (
; // Derive SectionId from all sections across all steps diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx index bc441088d..ab64962d2 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef } from 'react'; import { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; +import type { TranslateFn } from '@/lib/i18n'; import { getFieldErrorMessage, useAppForm } from '@/components/form/utils'; @@ -17,7 +18,7 @@ import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; const AUTOSAVE_DEBOUNCE_MS = 1000; -const createOverviewValidator = (t: (key: string) => string) => +const createOverviewValidator = (t: TranslateFn) => z.object({ stewardProfileId: z .string({ message: t('Select a steward for this process') }) diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx index 92e044a92..780551bb8 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/FieldCard.tsx @@ -10,6 +10,7 @@ import { ToggleButton } from '@op/ui/ToggleButton'; import { useRef } from 'react'; import { useTranslations } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n'; import type { FieldView } from '../../../proposalTemplate'; import { @@ -100,7 +101,7 @@ export function FieldCard({
{errors.map((error) => (

- {t(error)} + {t(error as TranslationKey)}

))}
diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx index 3bbf99949..0fa2ea39d 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/fieldRegistry.tsx @@ -3,6 +3,8 @@ import type { ComponentType } from 'react'; import type { IconType } from 'react-icons'; import { LuAlignLeft, LuChevronDown, LuLetterText } from 'react-icons/lu'; +import type { TranslationKey } from '@/lib/i18n'; + import type { FieldType, FieldView } from '../../../proposalTemplate'; import { FieldConfigDropdown } from './FieldConfigDropdown'; @@ -20,8 +22,8 @@ export interface FieldConfigProps { */ interface FieldTypeRegistryEntry { icon: IconType; - labelKey: string; - placeholderKey: string; + labelKey: TranslationKey; + placeholderKey: TranslationKey; ConfigComponent?: ComponentType; } @@ -52,7 +54,7 @@ export const FIELD_TYPE_REGISTRY: Record = { */ export const FIELD_CATEGORIES: { id: string; - labelKey: string; + labelKey: TranslationKey; types: FieldType[]; }[] = [ { @@ -71,11 +73,11 @@ export function getFieldIcon(type: FieldType): IconType { return FIELD_TYPE_REGISTRY[type].icon; } -export function getFieldLabelKey(type: FieldType): string { +export function getFieldLabelKey(type: FieldType): TranslationKey { return FIELD_TYPE_REGISTRY[type].labelKey; } -export function getFieldPlaceholderKey(type: FieldType): string { +export function getFieldPlaceholderKey(type: FieldType): TranslationKey { return FIELD_TYPE_REGISTRY[type].placeholderKey; } diff --git a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts index 6620d8a65..65660b59f 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts @@ -1,6 +1,8 @@ import { SYSTEM_FIELD_KEYS } from '@op/common/client'; import { z } from 'zod'; +import type { TranslationKey } from '@/lib/i18n'; + import { getFieldErrors, getFields } from '../../proposalTemplate'; import type { SectionId } from '../navigationConfig'; import type { ProcessBuilderInstanceData } from '../stores/useProcessBuilderStore'; @@ -11,7 +13,7 @@ export interface ValidationSummary { sections: Record; stepsRemaining: number; isReadyToLaunch: boolean; - checklist: { id: string; labelKey: string; isValid: boolean }[]; + checklist: { id: string; labelKey: TranslationKey; isValid: boolean }[]; } // ============ Zod Schemas ============ @@ -65,7 +67,7 @@ const SECTION_VALIDATORS: Record = { interface ChecklistItem { id: string; - labelKey: string; + labelKey: TranslationKey; validate: (data: ProcessBuilderInstanceData | undefined) => boolean; } diff --git a/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx b/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx index ff1ad7858..afba0bba8 100644 --- a/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx +++ b/apps/app/src/components/decisions/proposalEditor/ProposalFormRenderer.tsx @@ -5,7 +5,8 @@ import { Button } from '@op/ui/Button'; import { Select, SelectItem } from '@op/ui/Select'; import type { Editor } from '@tiptap/react'; -import { useTranslations } from '@/lib/i18n/routing'; +import { useTranslations } from '@/lib/i18n'; +import type { TranslateFn } from '@/lib/i18n'; import { CollaborativeBudgetField, @@ -67,7 +68,7 @@ function renderField( field: FieldDescriptor, draft: ProposalDraftFields, onFieldChange: (key: string, value: unknown) => void, - t: (key: string, params?: Record) => string, + t: TranslateFn, preview: boolean, onEditorFocus?: (editor: Editor) => void, onEditorBlur?: (editor: Editor) => void, diff --git a/apps/app/src/components/decisions/proposalEditor/handleMutationError.ts b/apps/app/src/components/decisions/proposalEditor/handleMutationError.ts index f53f7d37d..3319fbaf5 100644 --- a/apps/app/src/components/decisions/proposalEditor/handleMutationError.ts +++ b/apps/app/src/components/decisions/proposalEditor/handleMutationError.ts @@ -1,5 +1,7 @@ import { toast } from '@op/ui/Toast'; +import type { TranslateFn } from '@/lib/i18n'; + /** * Handles tRPC validation errors from mutation responses. * Displays appropriate toast messages based on error shape. @@ -7,7 +9,7 @@ import { toast } from '@op/ui/Toast'; export function handleMutationError( error: { data?: unknown; message?: string }, operationType: 'create' | 'update' | 'submit', - t: (key: string, params?: Record) => string, + t: TranslateFn, ) { console.error(`Failed to ${operationType} proposal:`, error); diff --git a/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx b/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx index d36cb4191..4ac54fe72 100644 --- a/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx +++ b/apps/app/src/components/screens/ComingSoon/ComingSoonScreen.tsx @@ -80,23 +80,25 @@ export const ComingSoonScreen = () => {

- {t('Built for')}{' '} - - {t('communities')} - {' '} - {t('ready to share power and co-create')}{' '} - - {t('social change')} - - {' — '} - {t('and')}{' '} - {t('funders')}{' '} - {t('who trust them to lead.')}{' '} + {t.rich( + 'Built for communities ready to share power and co-create social change — and funders who trust them to lead.', + { + fancy: (chunks: React.ReactNode) => ( + {chunks} + ), + }, + )} {t('No setup headaches. No learning curve.')} - {t('Common just works, instantly, for')}{' '} - {t('everyone')}. + {t.rich( + 'Common just works, instantly, for everyone.', + { + fancy: (chunks: React.ReactNode) => ( + {chunks} + ), + }, + )}

diff --git a/apps/app/src/components/screens/LandingScreen/index.tsx b/apps/app/src/components/screens/LandingScreen/index.tsx index caa570a5f..7a084acdb 100644 --- a/apps/app/src/components/screens/LandingScreen/index.tsx +++ b/apps/app/src/components/screens/LandingScreen/index.tsx @@ -143,7 +143,7 @@ const PostFeedSection = async ({ <> }> - + } />
diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index 578ca6eaf..aeaf1e2ea 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -10,7 +10,6 @@ "Professional title": "পেশাগত পদবী", "Enter your professional title": "আপনার পেশাগত পদবী লিখুন", "Continue": "চালিয়ে যান", - "Add your organization's details": "আপনার সংস্থার বিবরণ যোগ করুন", "We've pre-filled information about [ORGANIZATION].": "আমরা [ORGANIZATION] সম্পর্কে তথ্য আগেই পূরণ করে রেখেছি।", "Please review and make any necessary changes.": "অনুগ্রহ করে পর্যালোচনা করুন এবং প্রয়োজনীয় যেকোনো পরিবর্তন করুন।", "Name": "নাম", @@ -40,7 +39,6 @@ "We've found your organization": "আমরা আপনার সংস্থা খুঁজে পেয়েছি", "join_subheader": "আপনার ইমেইল ডোমেইনের ভিত্তিতে, আপনার এই সংস্থায় যোগ দেওয়ার অ্যাক্সেস আছে।", "Confirm Administrator Access": "প্রশাসক অ্যাক্সেস নিশ্চিত করুন", - "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "আপাতত, আমরা শুধুমাত্র প্রশাসক অ্যাকাউন্ট সমর্থন করছি। ভবিষ্যতে, আমরা সদস্য অ্যাকাউন্ট সমর্থন করতে সক্ষম হবো।", "Get Started": "শুরু করুন", "Get Started + Add My Organization": "শুরু করুন + আমার সংস্থা যোগ করুন", "Choose this if you also admin another organization": "আপনি যদি অন্য সংস্থার প্রশাসকও হন তাহলে এটি নির্বাচন করুন", @@ -48,29 +46,28 @@ "That didn't work": "এটি কাজ করেনি", "Something went wrong on our end. Please try again": "আমাদের দিক থেকে কিছু একটা ভুল হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন", "Must be at most 200 characters": "সর্বোচ্চ ২০০ অক্ষর হতে পারবে", - "That file type is not supported. Accepted types: {types}": "এ ধরনের ফাইল সমর্থিত নয়। অনুমোদিত ধরন: {types}", - "File too large. Maximum size: {maxSizeMB}MB": "ফাইল খুব বড়। সর্বোচ্চ আকার: {maxSizeMB}MB", + "That file type is not supported. Accepted types: {types}": "এই ফাইল টাইপ সমর্থিত নয়। গ্রহণযোগ্য টাইপ: {types}", "I have read and accept the": "আমি পড়েছি এবং গ্রহণ করেছি", "Terms of Use Overview": "ব্যবহারের শর্তাবলীর সংক্ষিপ্তসার", "Privacy Policy Overview": "গোপনীয়তা নীতির সংক্ষিপ্তসার", "Accept & Continue": "গ্রহণ করুন এবং চালিয়ে যান", "Funding information": "তহবিল সম্পর্কে তথ্য", "Specify if your organization is currently seeking funding and offers funding.": "আপনার সংস্থা বর্তমানে তহবিল খুঁজছে কিনা এবং তহবিল প্রদান করে কিনা তা নির্দিষ্ট করুন।", - "Is your organization seeking funding?": "আপনার সংস্থা কি তহবিল খুঁজছে?", + "Is your organization seeking funding?": "আপনার সংস্থা কি অর্থায়ন খুঁজছে?", "What types of funding are you seeking?": "আপনি কী ধরনের তহবিল খুঁজছেন?", - "Where can people contribute to your organization?": "মানুষ আপনার সংস্থায় কোথায় অবদান রাখতে পারে?", - "Add your contribution page here": "এখানে আপনার অবদানের পৃষ্ঠা যোগ করুন", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "আপনার দানের পৃষ্ঠা, ওপেন কালেক্টিভ, GoFundMe বা যেকোনো প্ল্যাটফর্মের একটি লিঙ্ক যোগ করুন যেখানে সমর্থকরা অবদান রাখতে পারেন অথবা কিভাবে তা করা যায় সে সম্পর্কে আরও জানতে পারেন।", - "Does your organization offer funding?": "আপনার সংস্থা কি তহবিল প্রদান করে?", - "Are organizations currently able to apply for funding?": "সংস্থাগুলি বর্তমানে তহবিলের জন্য আবেদন করতে সক্ষম?", - "What is your funding process?": "আপনার তহবিল প্রদানের প্রক্রিয়া কী?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "আপনি যে ধরনের তহবিল খুঁজছেন তার একটি বর্ণনা লিখুন (যেমন, অনুদান, সমন্বিত মূলধন, ইত্যাদি)", + "Where can people contribute to your organization?": "লোকেরা কোথায় আপনার সংস্থায় অবদান রাখতে পারে?", + "Add your contribution page here": "এখানে আপনার অবদান পৃষ্ঠা যোগ করুন", + "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "আপনার দান পৃষ্ঠা, Open Collective, GoFundMe বা যেকোনো প্ল্যাটফর্মে একটি লিঙ্ক যোগ করুন যেখানে সমর্থকরা অবদান রাখতে বা আরও জানতে পারেন।", + "Does your organization offer funding?": "আপনার সংস্থা কি অর্থায়ন প্রদান করে?", + "Are organizations currently able to apply for funding?": "সংস্থাগুলি কি বর্তমানে অর্থায়নের জন্য আবেদন করতে পারে?", + "What is your funding process?": "আপনার অর্থায়ন প্রক্রিয়া কী?", + "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "আপনি যে ধরনের অর্থায়ন খুঁজছেন তার একটি বিবরণ লিখুন (যেমন, অনুদান, সমন্বিত মূলধন, ইত্যাদি)", "Where can organizations apply?": "সংস্থাগুলি কোথায় আবেদন করতে পারে?", "Where can organizations learn more?": "সংস্থাগুলি কোথায় আরও জানতে পারে?", - "Add a link where organizations can apply for funding": "একটি লিঙ্ক যোগ করুন যেখানে সংস্থাগুলি তহবিলের জন্য আবেদন করতে পারে", - "Add a link to learn more about your funding process": "আপনার তহবিল প্রক্রিয়া সম্পর্কে আরও জানতে একটি লিঙ্ক যোগ করুন", - "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "একটি লিঙ্ক যোগ করুন যেখান থেকে অন্যরা আরও জানতে পারে যে কিভাবে তারা এখন বা ভবিষ্যতে আপনার সংস্থা থেকে তহবিল পেতে পারে।", - "Enter your organization's website here": "এখানে আপনার সংস্থার ওয়েবসাইটের ঠিকানা লিখুন", + "Add a link where organizations can apply for funding": "একটি লিঙ্ক যোগ করুন যেখানে সংস্থাগুলি অর্থায়নের জন্য আবেদন করতে পারে", + "Add a link to learn more about your funding process": "আপনার অর্থায়ন প্রক্রিয়া সম্পর্কে আরও জানতে একটি লিঙ্ক যোগ করুন", + "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "একটি লিঙ্ক যোগ করুন যেখানে অন্যরা জানতে পারে কীভাবে তারা এখন বা ভবিষ্যতে আপনার সংস্থা থেকে অর্থায়ন পেতে পারে।", + "Enter your organization's website here": "এখানে আপনার সংস্থার ওয়েবসাইট লিখুন", "Enter a brief description for your organization": "আপনার সংস্থার জন্য একটি সংক্ষিপ্ত বর্ণনা লিখুন", "Does your organization serve as a network or coalition with member organizations?": "আপনার সংস্থা কি সদস্য সংস্থা সহ একটি নেটওয়ার্ক বা জোট হিসেবে কাজ করে?", "Set up your individual profile.": "আপনার ব্যক্তিগত প্রোফাইল সেট আপ করুন।", @@ -91,7 +88,7 @@ "Enter a valid website address": "একটি বৈধ ওয়েবসাইট ঠিকানা লিখুন", "What types of funding are you offering?": "আপনি কী ধরনের তহবিল প্রদান করছেন?", "Select locations": "অবস্থান নির্বাচন করুন", - "Please try submitting the form again.": "অনুগ্রহ করে আবার ফর্ম জমা দেওয়ার চেষ্টা করুন।", + "Please try submitting the form again.": "অনুগ্রহ করে আবার ফর্মটি জমা দেওয়ার চেষ্টা করুন।", "Failed to join organization": "সংস্থায় যোগদান করতে ব্যর্থ", "Enter a name for your organization": "আপনার সংস্থার জন্য একটি নাম লিখুন", "Must be at most 100 characters": "সর্বোচ্চ ১০০ অক্ষর হতে পারবে", @@ -196,13 +193,12 @@ "Connection issue": "সংযোগে সমস্যা", "Please try sending the invite again.": "অনুগ্রহ করে আবার আমন্ত্রণ পাঠানোর চেষ্টা করুন।", "No connection": "কোনো সংযোগ নেই", - "Please check your internet connection and try again.": "অনুগ্রহ করে আপনার ইন্টারনেট সংযোগ চেক করুন এবং আবার চেষ্টা করুন।", + "Please check your internet connection and try again.": "আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।", "Invalid email address": "অবৈধ ইমেইল ঠিকানা", "Invalid email addresses": "অবৈধ ইমেইল ঠিকানা", "Loading roles...": "ভূমিকা লোড হচ্ছে...", "Invalid email": "অবৈধ ইমেইল", "is not a valid email address": "একটি বৈধ ইমেইল ঠিকানা নয়", - "Type emails followed by a comma...": "কমা দিয়ে পৃথক করে ইমেইল টাইপ করুন...", "Invite users": "ব্যবহারকারীদের আমন্ত্রণ জানান", "Invite others to Common": "অন্যদের Common-এ আমন্ত্রণ জানান", "Add to my organization": "আমার সংস্থায় যোগ করুন", @@ -214,7 +210,6 @@ "Add to organization": "সংস্থায় যোগ করুন", "Role": "ভূমিকা", "Invite new organizations onto Common.": "Common-এ নতুন সংস্থাকে আমন্ত্রণ জানান।", - "Separate multiple emails with commas": "একাধিক ইমেইল কমা দিয়ে পৃথক করুন", "Personal Message": "ব্যক্তিগত বার্তা", "Add a personal note to your invitation": "আপনার আমন্ত্রণে একটি ব্যক্তিগত নোট যোগ করুন", "relationship": "সম্পর্ক", @@ -479,7 +474,7 @@ "Cancel request": "অনুরোধ বাতিল করুন", "Delete an Account": "একটি অ্যাকাউন্ট মুছুন", "Delete account": "অ্যাকাউন্ট মুছুন", - "Delete my account": "Delete my account", + "Delete my account": "আমার অ্যাকাউন্ট মুছুন", "Account Deleted": "অ্যাকাউন্ট মুছে ফেলা হয়েছে", "Failed to delete account": "অ্যাকাউন্ট মুছতে ব্যর্থ", "Removing...": "মুছে ফেলা হচ্ছে...", @@ -715,19 +710,19 @@ "Enter a process name": "একটি প্রক্রিয়ার নাম লিখুন", "Enter a description": "একটি বিবরণ লিখুন", "This invite is no longer valid": "এই আমন্ত্রণটি আর বৈধ নয়", - "Complete these steps to launch": "Complete these steps to launch", - "Process name & description": "Process name & description", - "Add at least one phase": "Add at least one phase", - "Complete all required phase fields": "Complete all required phase fields", - "Create a proposal template": "Create a proposal template", - "Fix errors in the proposal template": "Fix errors in the proposal template", - "Invite members": "Invite members", - "Manage Process": "Manage Process", - "Invite Members": "Invite Members", - "Review": "Review", - "Submit Proposals": "Submit Proposals", - "Vote": "Vote", - "Advanced permissions": "Advanced permissions", + "Complete these steps to launch": "চালু করতে এই ধাপগুলো সম্পূর্ণ করুন", + "Process name & description": "প্রক্রিয়ার নাম ও বিবরণ", + "Add at least one phase": "কমপক্ষে একটি পর্যায় যোগ করুন", + "Complete all required phase fields": "সকল প্রয়োজনীয় পর্যায়ের ক্ষেত্র পূরণ করুন", + "Create a proposal template": "একটি প্রস্তাব টেমপ্লেট তৈরি করুন", + "Fix errors in the proposal template": "প্রস্তাব টেমপ্লেটের ত্রুটি সংশোধন করুন", + "Invite members": "সদস্যদের আমন্ত্রণ জানান", + "Manage Process": "প্রক্রিয়া পরিচালনা করুন", + "Invite Members": "সদস্যদের আমন্ত্রণ জানান", + "Review": "পর্যালোচনা", + "Submit Proposals": "প্রস্তাব জমা দিন", + "Vote": "ভোট দিন", + "Advanced permissions": "উন্নত অনুমতি", "Invite participants": "অংশগ্রহণকারীদের আমন্ত্রণ করুন", "Only invited participants can view and participate in this process": "শুধুমাত্র আমন্ত্রিত অংশগ্রহণকারীরা এই প্রক্রিয়া দেখতে এবং অংশগ্রহণ করতে পারেন", "Delete draft?": "খসড়া মুছুন?", @@ -738,7 +733,6 @@ "Delete draft": "খসড়া মুছুন", "Decision deleted successfully": "সিদ্ধান্ত সফলভাবে মুছে ফেলা হয়েছে", "Failed to delete decision": "সিদ্ধান্ত মুছে ফেলতে ব্যর্থ", - "Deleting...": "মুছে ফেলা হচ্ছে...", "A bridge to the": "একটি সেতু", "new economy.": "নতুন অর্থনীতির দিকে।", "Connect with your network.": "আপনার নেটওয়ার্কের সাথে সংযুক্ত হন।", @@ -766,10 +760,7 @@ "Failed to verify code": "কোড যাচাই করতে ব্যর্থ", "Relationship Requests": "সম্পর্কের অনুরোধ", "Active Decisions": "সক্রিয় সিদ্ধান্ত", - "will now appear as a": "এখন হিসেবে প্রদর্শিত হবে", "related organization": "সম্পর্কিত সংস্থা", - "on your profile.": "আপনার প্রোফাইলে।", - "Added you as a": "আপনাকে যোগ করেছে", "Specify your funding relationship": "আপনার অর্থায়ন সম্পর্ক নির্দিষ্ট করুন", "How do your organizations support each other?": "আপনার সংস্থাগুলি কীভাবে একে অপরকে সহায়তা করে?", "Your organization funds {organizationName}": "আপনার সংস্থা {organizationName}-কে অর্থায়ন করে", @@ -782,44 +773,17 @@ "Select language": "ভাষা নির্বাচন করুন", "{authorName}'s Post": "{authorName}-এর পোস্ট", "{count} comments": "{count}টি মন্তব্য", - "No comments yet. Be the first to comment!": "এখনও কোনো মন্তব্য নেই। প্রথম মন্তব্য করুন!", "Comment as {name}...": "{name} হিসেবে মন্তব্য করুন...", "Comment...": "মন্তব্য করুন...", - "Please check your internet connection and try again.": "আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।", - "Please try submitting the form again.": "অনুগ্রহ করে আবার ফর্মটি জমা দেওয়ার চেষ্টা করুন।", "Update failed": "আপডেট ব্যর্থ", - "That file type is not supported. Accepted types: {types}": "এই ফাইল টাইপ সমর্থিত নয়। গ্রহণযোগ্য টাইপ: {types}", "File too large. Maximum size: {size}MB": "ফাইল খুব বড়। সর্বাধিক আকার: {size}MB", - "Enter your organization's website here": "এখানে আপনার সংস্থার ওয়েবসাইট লিখুন", - "Is your organization seeking funding?": "আপনার সংস্থা কি অর্থায়ন খুঁজছে?", - "Where can people contribute to your organization?": "লোকেরা কোথায় আপনার সংস্থায় অবদান রাখতে পারে?", - "Add your contribution page here": "এখানে আপনার অবদান পৃষ্ঠা যোগ করুন", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "আপনার দান পৃষ্ঠা, Open Collective, GoFundMe বা যেকোনো প্ল্যাটফর্মে একটি লিঙ্ক যোগ করুন যেখানে সমর্থকরা অবদান রাখতে বা আরও জানতে পারেন।", - "Does your organization offer funding?": "আপনার সংস্থা কি অর্থায়ন প্রদান করে?", - "Are organizations currently able to apply for funding?": "সংস্থাগুলি কি বর্তমানে অর্থায়নের জন্য আবেদন করতে পারে?", - "What is your funding process?": "আপনার অর্থায়ন প্রক্রিয়া কী?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "আপনি যে ধরনের অর্থায়ন খুঁজছেন তার একটি বিবরণ লিখুন (যেমন, অনুদান, সমন্বিত মূলধন, ইত্যাদি)", - "Where can organizations apply?": "সংস্থাগুলি কোথায় আবেদন করতে পারে?", - "Where can organizations learn more?": "সংস্থাগুলি কোথায় আরও জানতে পারে?", - "Add a link where organizations can apply for funding": "একটি লিঙ্ক যোগ করুন যেখানে সংস্থাগুলি অর্থায়নের জন্য আবেদন করতে পারে", - "Add a link to learn more about your funding process": "আপনার অর্থায়ন প্রক্রিয়া সম্পর্কে আরও জানতে একটি লিঙ্ক যোগ করুন", - "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "একটি লিঙ্ক যোগ করুন যেখানে অন্যরা জানতে পারে কীভাবে তারা এখন বা ভবিষ্যতে আপনার সংস্থা থেকে অর্থায়ন পেতে পারে।", "File too large: {name}": "ফাইল খুব বড়: {name}", "Unsupported file type: {name}": "অসমর্থিত ফাইল টাইপ: {name}", "Navigation": "নেভিগেশন", "Log in": "লগ ইন", "Helping people decide together how to use their resources": "মানুষকে তাদের সম্পদ কীভাবে ব্যবহার করবে তা একসাথে সিদ্ধান্ত নিতে সাহায্য করা", "simply, intuitively, and effectively.": "সহজভাবে, স্বজ্ঞাতভাবে এবং কার্যকরভাবে।", - "Built for": "তৈরি করা হয়েছে", - "communities": "সম্প্রদায়", - "ready to share power and co-create": "ক্ষমতা ভাগ করে নিতে এবং সহ-সৃষ্টি করতে প্রস্তুত", - "social change": "সামাজিক পরিবর্তন", - "and": "এবং", - "funders": "অর্থদাতা", - "who trust them to lead.": "যারা তাদের নেতৃত্ব দেওয়ার জন্য বিশ্বাস করেন।", "No setup headaches. No learning curve.": "সেটআপের ঝামেলা নেই। শেখার বক্ররেখা নেই।", - "Common just works, instantly, for": "Common সহজেই কাজ করে, তাৎক্ষণিকভাবে,", - "everyone": "সবার জন্য", "Trusted by": "বিশ্বস্ত", "Get early access": "প্রাথমিক অ্যাক্সেস পান", "We're getting ready to welcome more organizations to Common.": "আমরা Common-এ আরও সংস্থাকে স্বাগত জানাতে প্রস্তুত হচ্ছি।", @@ -832,20 +796,18 @@ "Please enter your last name": "অনুগ্রহ করে আপনার পদবি লিখুন", "Please enter a valid email address": "অনুগ্রহ করে একটি বৈধ ইমেল ঠিকানা লিখুন", "We were not able to sign you up. Please try again.": "আমরা আপনাকে সাইন আপ করতে পারিনি। অনুগ্রহ করে আবার চেষ্টা করুন।", - "Common": "Common", + "Common": "কমন", "Get early access. We're getting ready to welcome more organizations to Common. Sign up now to hold your spot.": "প্রাথমিক অ্যাক্সেস পান। আমরা Common-এ আরও সংস্থাকে স্বাগত জানাতে প্রস্তুত হচ্ছি। আপনার জায়গা সংরক্ষণ করতে এখনই সাইন আপ করুন।", "First name": "প্রথম নাম", "First name here": "এখানে প্রথম নাম", "Last name": "পদবি", "Last name here": "এখানে পদবি", "Email address": "ইমেল ঠিকানা", - "Organization": "সংস্থা", "Organization name": "সংস্থার নাম", "Join the waitlist": "অপেক্ষা তালিকায় যোগ দিন", "You're on the list!": "আপনি তালিকায় আছেন!", "We can't wait to see you on Common, as an early collaborator in creating an economy that works for everyone.": "Common-এ আপনাকে দেখার জন্য অপেক্ষা করতে পারছি না, সবার জন্য কাজ করে এমন একটি অর্থনীতি তৈরিতে প্রাথমিক সহযোগী হিসেবে।", "We'll be in touch soon!": "আমরা শীঘ্রই যোগাযোগ করব!", - "Done": "সম্পন্ন", "Post deleted": "পোস্ট মুছে ফেলা হয়েছে", "Failed to delete post": "পোস্ট মুছতে ব্যর্থ হয়েছে", "Selected proposal": "নির্বাচিত প্রস্তাব", @@ -864,15 +826,63 @@ "Take me to Common": "আমাকে Common-এ নিয়ে যান", "No organizations found for this profile": "এই প্রোফাইলের জন্য কোনো সংস্থা পাওয়া যায়নি", "Could not load organizations": "সংস্থাগুলি লোড করা যায়নি", - "Results for": "ফলাফল", - "No results for": "কোনো ফলাফল নেই", "You may want to try using different keywords, checking for typos, or adjusting your filters.": "আপনি বিভিন্ন কীওয়ার্ড ব্যবহার করে, টাইপোর জন্য পরীক্ষা করে, বা আপনার ফিল্টারগুলি সামঞ্জস্য করে চেষ্টা করতে পারেন।", "No {type} found.": "কোনো {type} পাওয়া যায়নি।", "Could not load search results": "অনুসন্ধানের ফলাফল লোড করা যায়নি", "Timeline not set": "সময়সীমা নির্ধারিত হয়নি", "Section not found": "বিভাগ পাওয়া যায়নি", + "Built for communities ready to share power and co-create social change — and funders who trust them to lead.": "সম্প্রদায়গুলোর জন্য তৈরি যারা ক্ষমতা ভাগ করে নিতে এবং সামাজিক পরিবর্তন সহ-সৃষ্টি করতে প্রস্তুত — এবং অর্থদাতাদের জন্য যারা তাদের নেতৃত্বে বিশ্বাস করেন।", + "Common just works, instantly, for everyone.": "কমন সবার জন্য, তাৎক্ষণিকভাবে, সবার জন্য কাজ করে।", + "will now appear as a {relationship} on your profile.": "এখন আপনার প্রোফাইলে {relationship} হিসেবে প্রদর্শিত হবে।", + "Added you as a {relationship}": "আপনাকে {relationship} হিসেবে যোগ করা হয়েছে", + "Results for {query}": "{query}-এর ফলাফল", + "No results for {query}": "{query}-এর কোনো ফলাফল নেই", + "Individuals": "ব্যক্তি", + "individuals": "ব্যক্তি", + "organizations": "সংস্থা", + "Edit Profile": "প্রোফাইল সম্পাদনা করুন", + "Feature Requests & Support": "ফিচার অনুরোধ এবং সহায়তা", + "Log out": "লগ আউট", + "Category": "বিভাগ", "Participant Preview": "অংশগ্রহণকারীর পূর্বরূপ", "Review Proposal": "প্রস্তাব পর্যালোচনা", "pts": "পয়েন্ট", - "Yes/No": "হ্যাঁ/না" + "Yes/No": "হ্যাঁ/না", + "Field list": "ক্ষেত্র তালিকা", + "Hidden": "লুকানো", + "Failed to update proposal status": "প্রস্তাবের অবস্থা আপডেট করতে ব্যর্থ", + "Proposal shortlisted successfully": "প্রস্তাব সফলভাবে সংক্ষিপ্ত তালিকায় অন্তর্ভুক্ত", + "Proposal rejected successfully": "প্রস্তাব সফলভাবে প্রত্যাখ্যাত", + "Failed to update proposal visibility": "প্রস্তাবের দৃশ্যমানতা আপডেট করতে ব্যর্থ", + "is now hidden from active proposals.": "এখন সক্রিয় প্রস্তাব থেকে লুকানো।", + "is now visible in active proposals.": "এখন সক্রিয় প্রস্তাবে দৃশ্যমান।", + "Unhide proposal": "প্রস্তাব দেখান", + "Hide proposal": "প্রস্তাব লুকান", + "Update Proposal": "প্রস্তাব আপডেট করুন", + "Read full proposal": "সম্পূর্ণ প্রস্তাব পড়ুন", + "My ballot": "আমার ব্যালট", + "Click to download": "ডাউনলোড করতে ক্লিক করুন", + "Exporting...": "রপ্তানি হচ্ছে...", + "Export": "রপ্তানি", + "Your ballot is in!": "আপনার ব্যালট জমা হয়েছে!", + "Thank you for participating in the 2025 Community Vote. Your voice helps shape how we invest in our community.": "2025 কমিউনিটি ভোটে অংশগ্রহণের জন্য ধন্যবাদ। আপনার কণ্ঠ আমাদের সম্প্রদায়ে কীভাবে বিনিয়োগ করি তা গঠনে সহায়তা করে।", + "Here's what will happen next:": "পরবর্তীতে যা ঘটবে:", + "View all proposals": "সব প্রস্তাব দেখুন", + "Staff": "কর্মী", + "Please complete the following required fields:": "অনুগ্রহ করে নিম্নলিখিত প্রয়োজনীয় ক্ষেত্রগুলি পূরণ করুন:", + "General": "সাধারণ", + "Setting up": "সেটআপ হচ্ছে", + "Code of Conduct": "আচরণবিধি", + "Create Organization": "সংস্থা তৈরি করুন", + "Too many emails": "অনেক বেশি ইমেইল", + "You can invite a maximum of 100 emails at once. Please reduce the number and try again.": "আপনি একবারে সর্বাধিক 100টি ইমেইল আমন্ত্রণ জানাতে পারেন। অনুগ্রহ করে সংখ্যা কমান এবং আবার চেষ্টা করুন।", + "Separate multiple emails with commas or line breaks": "কমা বা লাইন ব্রেক দিয়ে একাধিক ইমেইল আলাদা করুন", + "Type emails followed by a comma or line break...": "কমা বা লাইন ব্রেক দিয়ে ইমেইল টাইপ করুন...", + "your organization": "আপনার সংস্থা", + "Add your organization's details": "আপনার সংস্থার বিবরণ যোগ করুন", + "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "আপাতত, আমরা শুধুমাত্র প্রশাসক অ্যাকাউন্ট সমর্থন করছি। ভবিষ্যতে, আমরা সদস্য অ্যাকাউন্ট সমর্থন করতে সক্ষম হবো।", + "Proposal Title": "প্রস্তাবের শিরোনাম", + "Start typing...": "টাইপ করা শুরু করুন...", + "Reason(s) and Insight(s)": "কারণ এবং অন্তর্দৃষ্টি", + "Placeholder": "প্লেসহোল্ডার" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 5008c1c05..dfaadf48d 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -10,7 +10,6 @@ "Professional title": "Professional title", "Enter your professional title": "Enter your professional title", "Continue": "Continue", - "Add your organization’s details": "Add your organization’s details", "We've pre-filled information about [ORGANIZATION].": "We've pre-filled information about [ORGANIZATION].", "Please review and make any necessary changes.": "Please review and make any necessary changes.", "Name": "Name", @@ -40,7 +39,6 @@ "We've found your organization": "We've found your organization", "join_subheader": "Based on your email domain, you have access to join this organization.", "Confirm Administrator Access": "Confirm Administrator Access", - "For now, we're only supporting administrator accounts. In the future, we’ll be able to support member accounts.": "For now, we're only supporting administrator accounts. In the future, we’ll be able to support member accounts.", "Get Started": "Get Started", "Get Started + Add My Organization": "Get Started + Add My Organization", "Choose this if you also admin another organization": "Choose this if you also admin another organization", @@ -49,7 +47,6 @@ "Something went wrong on our end. Please try again": "Something went wrong on our end. Please try again", "Must be at most 200 characters": "Must be at most 200 characters", "That file type is not supported. Accepted types: {types}": "That file type is not supported. Accepted types: {types}", - "File too large. Maximum size: {maxSizeMB}MB": "File too large. Maximum size: {maxSizeMB}MB", "I have read and accept the": "I have read and accept the", "Terms of Use Overview": "Terms of Use Overview", "Privacy Policy Overview": "Privacy Policy Overview", @@ -202,7 +199,6 @@ "Loading roles...": "Loading roles...", "Invalid email": "Invalid email", "is not a valid email address": "is not a valid email address", - "Type emails followed by a comma...": "Type emails followed by a comma...", "Invite users": "Invite users", "Invite others to Common": "Invite others to Common", "Add to my organization": "Add to my organization", @@ -214,7 +210,6 @@ "Add to organization": "Add to organization", "Role": "Role", "Invite new organizations onto Common.": "Invite new organizations onto Common.", - "Separate multiple emails with commas": "Separate multiple emails with commas", "Personal Message": "Personal Message", "Add a personal note to your invitation": "Add a personal note to your invitation", "relationship": "relationship", @@ -731,7 +726,6 @@ "Delete draft": "Delete draft", "Decision deleted successfully": "Decision deleted successfully", "Failed to delete decision": "Failed to delete decision", - "Deleting...": "Deleting...", "A bridge to the": "A bridge to the", "new economy.": "new economy.", "Connect with your network.": "Connect with your network.", @@ -759,10 +753,7 @@ "Failed to verify code": "Failed to verify code", "Relationship Requests": "Relationship Requests", "Active Decisions": "Active Decisions", - "will now appear as a": "will now appear as a", "related organization": "related organization", - "on your profile.": "on your profile.", - "Added you as a": "Added you as a", "Specify your funding relationship": "Specify your funding relationship", "How do your organizations support each other?": "How do your organizations support each other?", "Your organization funds {organizationName}": "Your organization funds {organizationName}", @@ -775,44 +766,17 @@ "Select language": "Select language", "{authorName}'s Post": "{authorName}'s Post", "{count} comments": "{count} comments", - "No comments yet. Be the first to comment!": "No comments yet. Be the first to comment!", "Comment as {name}...": "Comment as {name}...", "Comment...": "Comment...", - "Please check your internet connection and try again.": "Please check your internet connection and try again.", - "Please try submitting the form again.": "Please try submitting the form again.", "Update failed": "Update failed", - "That file type is not supported. Accepted types: {types}": "That file type is not supported. Accepted types: {types}", "File too large. Maximum size: {size}MB": "File too large. Maximum size: {size}MB", - "Enter your organization's website here": "Enter your organization's website here", - "Is your organization seeking funding?": "Is your organization seeking funding?", - "Where can people contribute to your organization?": "Where can people contribute to your organization?", - "Add your contribution page here": "Add your contribution page here", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.", - "Does your organization offer funding?": "Does your organization offer funding?", - "Are organizations currently able to apply for funding?": "Are organizations currently able to apply for funding?", - "What is your funding process?": "What is your funding process?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)", - "Where can organizations apply?": "Where can organizations apply?", - "Where can organizations learn more?": "Where can organizations learn more?", - "Add a link where organizations can apply for funding": "Add a link where organizations can apply for funding", - "Add a link to learn more about your funding process": "Add a link to learn more about your funding process", - "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.", "File too large: {name}": "File too large: {name}", "Unsupported file type: {name}": "Unsupported file type: {name}", "Navigation": "Navigation", "Log in": "Log in", "Helping people decide together how to use their resources": "Helping people decide together how to use their resources", "simply, intuitively, and effectively.": "simply, intuitively, and effectively.", - "Built for": "Built for", - "communities": "communities", - "ready to share power and co-create": "ready to share power and co-create", - "social change": "social change", - "and": "and", - "funders": "funders", - "who trust them to lead.": "who trust them to lead.", "No setup headaches. No learning curve.": "No setup headaches. No learning curve.", - "Common just works, instantly, for": "Common just works, instantly, for", - "everyone": "everyone", "Trusted by": "Trusted by", "Get early access": "Get early access", "We're getting ready to welcome more organizations to Common.": "We're getting ready to welcome more organizations to Common.", @@ -832,13 +796,11 @@ "Last name": "Last name", "Last name here": "Last name here", "Email address": "Email address", - "Organization": "Organization", "Organization name": "Organization name", "Join the waitlist": "Join the waitlist", "You're on the list!": "You're on the list!", "We can't wait to see you on Common, as an early collaborator in creating an economy that works for everyone.": "We can't wait to see you on Common, as an early collaborator in creating an economy that works for everyone.", "We'll be in touch soon!": "We'll be in touch soon!", - "Done": "Done", "Post deleted": "Post deleted", "Failed to delete post": "Failed to delete post", "Selected proposal": "Selected proposal", @@ -857,15 +819,63 @@ "Take me to Common": "Take me to Common", "No organizations found for this profile": "No organizations found for this profile", "Could not load organizations": "Could not load organizations", - "Results for": "Results for", - "No results for": "No results for", "You may want to try using different keywords, checking for typos, or adjusting your filters.": "You may want to try using different keywords, checking for typos, or adjusting your filters.", "No {type} found.": "No {type} found.", "Could not load search results": "Could not load search results", "Timeline not set": "Timeline not set", "Section not found": "Section not found", + "Built for communities ready to share power and co-create social change — and funders who trust them to lead.": "Built for communities ready to share power and co-create social change — and funders who trust them to lead.", + "Common just works, instantly, for everyone.": "Common just works, instantly, for everyone.", + "will now appear as a {relationship} on your profile.": "will now appear as a {relationship} on your profile.", + "Added you as a {relationship}": "Added you as a {relationship}", + "Results for {query}": "Results for {query}", + "No results for {query}": "No results for {query}", + "Individuals": "Individuals", + "individuals": "individuals", + "organizations": "organizations", + "Edit Profile": "Edit Profile", + "Feature Requests & Support": "Feature Requests & Support", + "Log out": "Log out", + "Category": "Category", "Participant Preview": "Participant Preview", "Review Proposal": "Review Proposal", "pts": "pts", - "Yes/No": "Yes/No" + "Yes/No": "Yes/No", + "Field list": "Field list", + "Hidden": "Hidden", + "Failed to update proposal status": "Failed to update proposal status", + "Proposal shortlisted successfully": "Proposal shortlisted successfully", + "Proposal rejected successfully": "Proposal rejected successfully", + "Failed to update proposal visibility": "Failed to update proposal visibility", + "is now hidden from active proposals.": "is now hidden from active proposals.", + "is now visible in active proposals.": "is now visible in active proposals.", + "Unhide proposal": "Unhide proposal", + "Hide proposal": "Hide proposal", + "Update Proposal": "Update Proposal", + "Read full proposal": "Read full proposal", + "My ballot": "My ballot", + "Click to download": "Click to download", + "Exporting...": "Exporting...", + "Export": "Export", + "Your ballot is in!": "Your ballot is in!", + "Thank you for participating in the 2025 Community Vote. Your voice helps shape how we invest in our community.": "Thank you for participating in the 2025 Community Vote. Your voice helps shape how we invest in our community.", + "Here's what will happen next:": "Here's what will happen next:", + "View all proposals": "View all proposals", + "Staff": "Staff", + "Please complete the following required fields:": "Please complete the following required fields:", + "General": "General", + "Setting up": "Setting up", + "Code of Conduct": "Code of Conduct", + "Create Organization": "Create Organization", + "Too many emails": "Too many emails", + "You can invite a maximum of 100 emails at once. Please reduce the number and try again.": "You can invite a maximum of 100 emails at once. Please reduce the number and try again.", + "Separate multiple emails with commas or line breaks": "Separate multiple emails with commas or line breaks", + "Type emails followed by a comma or line break...": "Type emails followed by a comma or line break...", + "your organization": "your organization", + "Add your organization's details": "Add your organization's details", + "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.", + "Proposal Title": "Proposal Title", + "Start typing...": "Start typing...", + "Reason(s) and Insight(s)": "Reason(s) and Insight(s)", + "Placeholder": "Placeholder" } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index e1b4e0931..d87539f70 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -9,7 +9,6 @@ "Professional title": "Título profesional", "Enter your professional title": "Ingresa tu título profesional", "Continue": "Continuar", - "Add your organization's details": "Agrega los detalles de tu organización", "We've pre-filled information about [ORGANIZATION].": "Hemos prellenado la información sobre [ORGANIZATION].", "Please review and make any necessary changes.": "Por favor revisa y haz los cambios necesarios.", "Name": "Nombre", @@ -39,7 +38,6 @@ "We've found your organization": "Hemos encontrado tu organización", "join_subheader": "Basado en tu dominio de correo electrónico, tienes acceso para unirte a esta organización.", "Confirm Administrator Access": "Confirmar acceso de administrador", - "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "Por ahora, solo estamos apoyando cuentas de administrador. En el futuro, podremos apoyar cuentas de miembros.", "Get Started": "Comenzar", "Get Started + Add My Organization": "Comenzar + Agregar Mi Organización", "Choose this if you also admin another organization": "Elige esto si también administras otra organización", @@ -48,26 +46,25 @@ "Something went wrong on our end. Please try again": "Algo salió mal de nuestro lado. Por favor intenta de nuevo", "Must be at most 200 characters": "Máximo 200 caracteres", "That file type is not supported. Accepted types: {types}": "Ese tipo de archivo no es compatible. Tipos aceptados: {types}", - "File too large. Maximum size: {maxSizeMB}MB": "Archivo demasiado grande. Tamaño máximo: {maxSizeMB}MB", "I have read and accept the": "He leído y acepto los", "Terms of Use Overview": "Resumen de términos de servicio", "Privacy Policy Overview": "Resumen de política de privacidad", "Accept & Continue": "Aceptar y continuar", "Funding information": "Información de financiamiento", "Specify if your organization is currently seeking funding and offers funding.": "Indica si tu organización actualmente busca y ofrece financiamiento.", - "Is your organization seeking funding?": "¿Tu organización está buscando financiamiento?", + "Is your organization seeking funding?": "¿Tu organización busca financiamiento?", "What types of funding are you seeking?": "¿Qué tipos de financiamiento estás buscando?", "Where can people contribute to your organization?": "¿Dónde pueden las personas contribuir a tu organización?", "Add your contribution page here": "Agrega tu página de contribuciones aquí", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Agrega un enlace a tu página de donaciones, Open Collective, GoFundMe o cualquier plataforma donde las personas que deseen apoyar puedan contribuir o aprender más sobre cómo hacerlo.", + "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Agrega un enlace a tu página de donaciones, Open Collective, GoFundMe o cualquier plataforma donde los simpatizantes puedan contribuir o aprender más.", "Does your organization offer funding?": "¿Tu organización ofrece financiamiento?", - "Are organizations currently able to apply for funding?": "¿Las organizaciones pueden solicitar para financiamiento actualmente?", + "Are organizations currently able to apply for funding?": "¿Las organizaciones pueden actualmente solicitar financiamiento?", "What is your funding process?": "¿Cuál es tu proceso de financiamiento?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Ingresa una descripción del tipo de financiamiento que estás buscando (ej., subvenciones, capital integrado, etc.)", - "Where can organizations apply?": "¿Dónde pueden presentar su solicitud las organizaciones?", - "Where can organizations learn more?": "¿Dónde pueden las organizaciones aprender más?", + "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Ingresa una descripción del tipo de financiamiento que buscas (por ejemplo, subvenciones, capital integrado, etc.)", + "Where can organizations apply?": "¿Dónde pueden las organizaciones aplicar?", + "Where can organizations learn more?": "¿Dónde pueden las organizaciones obtener más información?", "Add a link where organizations can apply for funding": "Agrega un enlace donde las organizaciones puedan solicitar financiamiento", - "Add a link to learn more about your funding process": "Agrega un enlace para aprender más sobre tu proceso de financiamiento", + "Add a link to learn more about your funding process": "Agrega un enlace para saber más sobre tu proceso de financiamiento", "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Agrega un enlace donde otros puedan aprender más sobre cómo podrían recibir financiamiento de tu organización ahora o en el futuro.", "Enter your organization's website here": "Ingresa el sitio web de tu organización aquí", "Enter a brief description for your organization": "Ingresa una breve descripción de tu organización", @@ -90,7 +87,7 @@ "Enter a valid website address": "Ingresa una dirección web válida", "What types of funding are you offering?": "¿Qué tipos de financiamiento estás ofreciendo?", "Select locations": "Seleccionar ubicaciones", - "Please try submitting the form again.": "Por favor intenta enviar el formulario de nuevo.", + "Please try submitting the form again.": "Por favor, intenta enviar el formulario de nuevo.", "Failed to join organization": "Error al unirse a la organización", "Enter a name for your organization": "Ingresa un nombre para tu organización", "Must be at most 100 characters": "Máximo 100 caracteres", @@ -195,13 +192,12 @@ "Connection issue": "Problema de conexión", "Please try sending the invite again.": "Por favor intenta enviar la invitación de nuevo.", "No connection": "Sin conexión", - "Please check your internet connection and try again.": "Por favor verifica tu conexión a internet e intenta de nuevo.", + "Please check your internet connection and try again.": "Por favor, verifica tu conexión a internet e intenta de nuevo.", "Invalid email address": "Dirección de correo inválida", "Invalid email addresses": "Direcciones de correo inválidas", "Loading roles...": "Cargando roles...", "Invalid email": "Correo inválido", "is not a valid email address": "no es una dirección de correo válida", - "Type emails followed by a comma or line break...": "Escribe los correos seguidos de una coma o salto de línea...", "Invite users": "Invitar usuarios", "Invite others to Common": "Invitar a otros a Common", "Add to my organization": "Agregar a mi organización", @@ -213,7 +209,6 @@ "Add to organization": "Agregar a organización", "Role": "Rol", "Invite new organizations onto Common.": "Invita nuevas organizaciones a Common.", - "Separate multiple emails with commas or line breaks": "Separa múltiples correos con comas o saltos de línea", "Personal Message": "Mensaje personal", "Add a personal note to your invitation": "Agrega una nota personal a tu invitación", "relationship": "relación", @@ -730,7 +725,6 @@ "Delete draft": "Eliminar borrador", "Decision deleted successfully": "Decisión eliminada exitosamente", "Failed to delete decision": "Error al eliminar la decisión", - "Deleting...": "Eliminando...", "A bridge to the": "Un puente hacia la", "new economy.": "nueva economía.", "Connect with your network.": "Conéctate con tu red.", @@ -758,10 +752,7 @@ "Failed to verify code": "Error al verificar el código", "Relationship Requests": "Solicitudes de relación", "Active Decisions": "Decisiones activas", - "will now appear as a": "ahora aparecerá como", "related organization": "organización relacionada", - "on your profile.": "en tu perfil.", - "Added you as a": "Te agregó como", "Specify your funding relationship": "Especifica tu relación de financiamiento", "How do your organizations support each other?": "¿Cómo se apoyan mutuamente sus organizaciones?", "Your organization funds {organizationName}": "Tu organización financia a {organizationName}", @@ -774,44 +765,17 @@ "Select language": "Seleccionar idioma", "{authorName}'s Post": "Publicación de {authorName}", "{count} comments": "{count} comentarios", - "No comments yet. Be the first to comment!": "Aún no hay comentarios. ¡Sé el primero en comentar!", "Comment as {name}...": "Comentar como {name}...", "Comment...": "Comentar...", - "Please check your internet connection and try again.": "Por favor, verifica tu conexión a internet e intenta de nuevo.", - "Please try submitting the form again.": "Por favor, intenta enviar el formulario de nuevo.", "Update failed": "Actualización fallida", - "That file type is not supported. Accepted types: {types}": "Ese tipo de archivo no es compatible. Tipos aceptados: {types}", "File too large. Maximum size: {size}MB": "Archivo demasiado grande. Tamaño máximo: {size}MB", - "Enter your organization's website here": "Ingresa el sitio web de tu organización aquí", - "Is your organization seeking funding?": "¿Tu organización busca financiamiento?", - "Where can people contribute to your organization?": "¿Dónde pueden las personas contribuir a tu organización?", - "Add your contribution page here": "Agrega tu página de contribuciones aquí", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Agrega un enlace a tu página de donaciones, Open Collective, GoFundMe o cualquier plataforma donde los simpatizantes puedan contribuir o aprender más.", - "Does your organization offer funding?": "¿Tu organización ofrece financiamiento?", - "Are organizations currently able to apply for funding?": "¿Las organizaciones pueden actualmente solicitar financiamiento?", - "What is your funding process?": "¿Cuál es tu proceso de financiamiento?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Ingresa una descripción del tipo de financiamiento que buscas (por ejemplo, subvenciones, capital integrado, etc.)", - "Where can organizations apply?": "¿Dónde pueden las organizaciones aplicar?", - "Where can organizations learn more?": "¿Dónde pueden las organizaciones obtener más información?", - "Add a link where organizations can apply for funding": "Agrega un enlace donde las organizaciones puedan solicitar financiamiento", - "Add a link to learn more about your funding process": "Agrega un enlace para saber más sobre tu proceso de financiamiento", - "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Agrega un enlace donde otros puedan aprender más sobre cómo podrían recibir financiamiento de tu organización ahora o en el futuro.", "File too large: {name}": "Archivo demasiado grande: {name}", "Unsupported file type: {name}": "Tipo de archivo no compatible: {name}", "Navigation": "Navegación", "Log in": "Iniciar sesión", "Helping people decide together how to use their resources": "Ayudando a las personas a decidir juntas cómo usar sus recursos", "simply, intuitively, and effectively.": "de forma simple, intuitiva y efectiva.", - "Built for": "Creado para", - "communities": "comunidades", - "ready to share power and co-create": "listas para compartir el poder y co-crear", - "social change": "cambio social", - "and": "y", - "funders": "financiadores", - "who trust them to lead.": "que confían en ellas para liderar.", "No setup headaches. No learning curve.": "Sin complicaciones de configuración. Sin curva de aprendizaje.", - "Common just works, instantly, for": "Common simplemente funciona, al instante, para", - "everyone": "todos", "Trusted by": "Confiado por", "Get early access": "Obtén acceso anticipado", "We're getting ready to welcome more organizations to Common.": "Nos estamos preparando para dar la bienvenida a más organizaciones a Common.", @@ -831,13 +795,11 @@ "Last name": "Apellido", "Last name here": "Apellido aquí", "Email address": "Correo electrónico", - "Organization": "Organización", "Organization name": "Nombre de la organización", "Join the waitlist": "Unirse a la lista de espera", "You're on the list!": "¡Estás en la lista!", "We can't wait to see you on Common, as an early collaborator in creating an economy that works for everyone.": "No podemos esperar a verte en Common, como un colaborador temprano en la creación de una economía que funcione para todos.", "We'll be in touch soon!": "¡Nos pondremos en contacto pronto!", - "Done": "Listo", "Post deleted": "Publicación eliminada", "Failed to delete post": "No se pudo eliminar la publicación", "Selected proposal": "Propuesta seleccionada", @@ -856,15 +818,64 @@ "Take me to Common": "Llévame a Common", "No organizations found for this profile": "No se encontraron organizaciones para este perfil", "Could not load organizations": "No se pudieron cargar las organizaciones", - "Results for": "Resultados para", - "No results for": "Sin resultados para", "You may want to try using different keywords, checking for typos, or adjusting your filters.": "Puede que desees intentar usar palabras clave diferentes, verificar errores tipográficos o ajustar tus filtros.", "No {type} found.": "No se encontraron {type}.", "Could not load search results": "No se pudieron cargar los resultados de búsqueda", "Timeline not set": "Cronograma no establecido", "Section not found": "Sección no encontrada", + "Post": "Publicar", + "Built for communities ready to share power and co-create social change — and funders who trust them to lead.": "Creado para comunidades listas para compartir poder y co-crear cambio social — y financiadores que confían en ellas para liderar.", + "Common just works, instantly, for everyone.": "Common simplemente funciona, al instante, para todos.", + "will now appear as a {relationship} on your profile.": "ahora aparecerá como {relationship} en tu perfil.", + "Added you as a {relationship}": "Te agregó como {relationship}", + "Results for {query}": "Resultados para {query}", + "No results for {query}": "Sin resultados para {query}", + "Individuals": "Individuales", + "individuals": "individuales", + "organizations": "organizaciones", + "Edit Profile": "Editar perfil", + "Feature Requests & Support": "Solicitudes de funciones y soporte", + "Log out": "Cerrar sesión", + "Category": "Categoría", "Participant Preview": "Vista previa del participante", "Review Proposal": "Revisar propuesta", "pts": "pts", - "Yes/No": "Sí/No" + "Yes/No": "Sí/No", + "Field list": "Lista de campos", + "Hidden": "Oculto", + "Failed to update proposal status": "Error al actualizar el estado de la propuesta", + "Proposal shortlisted successfully": "Propuesta preseleccionada con éxito", + "Proposal rejected successfully": "Propuesta rechazada con éxito", + "Failed to update proposal visibility": "Error al actualizar la visibilidad de la propuesta", + "is now hidden from active proposals.": "ahora está oculto de las propuestas activas.", + "is now visible in active proposals.": "ahora es visible en las propuestas activas.", + "Unhide proposal": "Mostrar propuesta", + "Hide proposal": "Ocultar propuesta", + "Update Proposal": "Actualizar propuesta", + "Read full proposal": "Leer propuesta completa", + "My ballot": "Mi boleta", + "Click to download": "Haz clic para descargar", + "Exporting...": "Exportando...", + "Export": "Exportar", + "Your ballot is in!": "¡Tu voto ha sido registrado!", + "Thank you for participating in the 2025 Community Vote. Your voice helps shape how we invest in our community.": "Gracias por participar en la Votación Comunitaria 2025. Tu voz ayuda a definir cómo invertimos en nuestra comunidad.", + "Here's what will happen next:": "Esto es lo que pasará a continuación:", + "View all proposals": "Ver todas las propuestas", + "Staff": "Personal", + "Please complete the following required fields:": "Por favor complete los siguientes campos obligatorios:", + "General": "General", + "Setting up": "Configurando", + "Code of Conduct": "Código de conducta", + "Create Organization": "Crear organización", + "Too many emails": "Demasiados correos electrónicos", + "You can invite a maximum of 100 emails at once. Please reduce the number and try again.": "Puedes invitar un máximo de 100 correos electrónicos a la vez. Por favor reduce el número e intenta de nuevo.", + "Separate multiple emails with commas or line breaks": "Separe varios correos electrónicos con comas o saltos de línea", + "Type emails followed by a comma or line break...": "Escriba correos electrónicos seguidos de una coma o salto de línea...", + "your organization": "tu organización", + "Add your organization's details": "Agrega los detalles de tu organización", + "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "Por ahora, solo estamos apoyando cuentas de administrador. En el futuro, podremos apoyar cuentas de miembros.", + "Proposal Title": "Título de la Propuesta", + "Start typing...": "Empieza a escribir...", + "Reason(s) and Insight(s)": "Razón(es) y perspectiva(s)", + "Placeholder": "Marcador de posición" } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index d8ac057f0..538bef132 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -10,7 +10,6 @@ "Professional title": "Titre professionnel", "Enter your professional title": "Introduisez vos informations", "Continue": "Continuer", - "Add your organization's details": "Ajoutez les détails de votre organisation", "We've pre-filled information about [ORGANIZATION].": "Nous avons pré-rempli les informations de [ORGANIZATION].", "Please review and make any necessary changes.": "Veuillez vérifier et apporter les modifications nécessaires.", "Name": "Nom", @@ -40,7 +39,6 @@ "We've found your organization": "Nous avons trouvé votre organisation", "join_subheader": "En fonction du domaine de votre courriel, vous avez accès pour rejoindre cette organisation.", "Confirm Administrator Access": "Confirmer l'accès administrateur", - "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "Pour l'instant, nous ne gérons que les comptes administrateurs. À l'avenir, nous serons en mesure de gérer les comptes membres.", "Get Started": "Commencer", "Get Started + Add My Organization": "Commencer + Ajouter Mon Organisation", "Choose this if you also admin another organization": "Choisissez ceci si vous administrez également une autre organisation", @@ -48,28 +46,27 @@ "That didn't work": "Cela n'a pas fonctionné", "Something went wrong on our end. Please try again": "Une erreur s'est produite de notre côté. Veuillez réessayer.", "Must be at most 200 characters": "Doit contenir au maximum 200 caractères", - "That file type is not supported. Accepted types: {types}": "Ce type de fichier n'est pas compatible. Types compatibles : {types}", - "File too large. Maximum size: {maxSizeMB}MB": "Le fichier est trop gros. La taille maximale est : {maxSizeMB}MB", + "That file type is not supported. Accepted types: {types}": "Ce type de fichier n'est pas pris en charge. Types acceptés : {types}", "I have read and accept the": "J'ai lu et j'accepte le(s)", "Terms of Use Overview": "Aperçu des conditions d'utilisation", "Privacy Policy Overview": "Aperçu de la politique de confidentialité", "Accept & Continue": "Accepter et continuer", "Funding information": "Informations sur le financement", "Specify if your organization is currently seeking funding and offers funding.": "Indiquez si votre organisation recherche actuellement des financements et offre des financements.", - "Is your organization seeking funding?": "Votre organisation recherche-t-elle des financements ?", + "Is your organization seeking funding?": "Votre organisation cherche-t-elle du financement ?", "What types of funding are you seeking?": "Quels types de financement recherchez-vous ?", "Where can people contribute to your organization?": "Où les gens peuvent-ils contribuer à votre organisation ?", "Add your contribution page here": "Ajoutez votre page de contribution ici", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Ajoutez un lien vers votre page de dons, Open Collective, GoFundMe ou toute autre plateforme où vos donateurs peuvent contribuer ou trouver plus d'information sur comment le faire.", - "Does your organization offer funding?": "Votre organisation offre-t-elle des financements ?", - "Are organizations currently able to apply for funding?": "Les organisations peuvent-elles actuellement demander un financement ?", + "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Ajoutez un lien vers votre page de don, Open Collective, GoFundMe ou toute plateforme où les sympathisants peuvent contribuer ou en savoir plus.", + "Does your organization offer funding?": "Votre organisation offre-t-elle du financement ?", + "Are organizations currently able to apply for funding?": "Les organisations peuvent-elles actuellement postuler pour un financement ?", "What is your funding process?": "Quel est votre processus de financement ?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Décrivez le type de financement que vous recherchez (par exemple, subventions, capital intégré, etc.)", + "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Entrez une description du type de financement que vous recherchez (par ex., subventions, capital intégré, etc.)", "Where can organizations apply?": "Où les organisations peuvent-elles postuler ?", - "Where can organizations learn more?": "Où les organisations peuvent-elles obtenir plus d'informations ?", - "Add a link where organizations can apply for funding": "Ajoutez un lien où les organisations peuvent demander un financement", - "Add a link to learn more about your funding process": "Ajoutez un lien où on peut obtenir plus d'informations sur votre processus de financement", - "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Ajoutez un lien où d'autres peuvent obtenir plus d'informations sur la façon dont ils pourraient recevoir un financement de votre organisation maintenant ou à l'avenir.", + "Where can organizations learn more?": "Où les organisations peuvent-elles en savoir plus ?", + "Add a link where organizations can apply for funding": "Ajoutez un lien où les organisations peuvent postuler pour un financement", + "Add a link to learn more about your funding process": "Ajoutez un lien pour en savoir plus sur votre processus de financement", + "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Ajoutez un lien où d'autres peuvent en savoir plus sur la façon dont ils pourraient recevoir un financement de votre organisation maintenant ou à l'avenir.", "Enter your organization's website here": "Entrez le site web de votre organisation ici", "Enter a brief description for your organization": "Écrivez une brève description de votre organisation", "Does your organization serve as a network or coalition with member organizations?": "Votre organisation sert-elle de réseau ou de coalition avec des organisations membres ?", @@ -159,7 +156,7 @@ "Loading proposal...": "En chargeant la proposition", "Submitted on": "Soumis le", "Loading comments...": "En train de charger les commentaires...", - "No comments yet. Be the first to comment!": "Aucun commentaire. Soyez le premier à commenter !", + "No comments yet. Be the first to comment!": "Aucun commentaire pour le moment. Soyez le premier à commenter !", "SHARE YOUR IDEAS.": "PARTAGEZ VOS IDÉES.", "YOUR BALLOT IS IN.": "VOTRE VOTE EST ENREGISTRÉ.", "COMMITTEE DELIBERATION.": "DÉLIBÉRATION DU COMITÉ.", @@ -202,7 +199,6 @@ "Loading roles...": "En train de charger les rôles...", "Invalid email": "Courriel invalide", "is not a valid email address": "n'es pas un courriel valide", - "Type emails followed by a comma or line break...": "Entrez les courriels suivis d'une virgule ou d'un saut de ligne...", "Invite users": "Inviter des utilisateurs", "Invite others to Common": "Inviter d'autres sur Common", "Add to my organization": "Ajouter à mon organisation", @@ -214,7 +210,6 @@ "Add to organization": "Ajouter à l'organisation", "Role": "Rôle", "Invite new organizations onto Common.": "Invitez de nouvelles organisations sur Common.", - "Separate multiple emails with commas or line breaks": "Séparez plusieurs courriels par des virgules ou des sauts de ligne", "Personal Message": "Message personnel", "Add a personal note to your invitation": "Ajoutez une note personnelle à votre invitation", "relationship": "relation", @@ -262,7 +257,7 @@ "Delete phase": "Supprimer la phase", "Are you sure you want to delete this phase? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer cette phase ? Cette action est irréversible.", "Delete": "Supprimer", - "Deleting...": "Suppression en cours...", + "Deleting...": "Suppression...", "Failed to delete proposal": "Échec de la suppression de la proposition", "Failed to create proposal": "Échec de la création de la proposition", "Failed to update proposal": "Échec de la mise à jour de la proposition", @@ -730,7 +725,6 @@ "Delete draft": "Supprimer le brouillon", "Decision deleted successfully": "Décision supprimée avec succès", "Failed to delete decision": "Échec de la suppression de la décision", - "Deleting...": "Suppression...", "A bridge to the": "Un pont vers la", "new economy.": "nouvelle économie.", "Connect with your network.": "Connectez-vous avec votre réseau.", @@ -758,10 +752,7 @@ "Failed to verify code": "Échec de la vérification du code", "Relationship Requests": "Demandes de relation", "Active Decisions": "Décisions actives", - "will now appear as a": "apparaîtra désormais comme", "related organization": "organisation liée", - "on your profile.": "sur votre profil.", - "Added you as a": "Vous a ajouté comme", "Specify your funding relationship": "Spécifiez votre relation de financement", "How do your organizations support each other?": "Comment vos organisations se soutiennent-elles mutuellement ?", "Your organization funds {organizationName}": "Votre organisation finance {organizationName}", @@ -774,44 +765,17 @@ "Select language": "Sélectionner la langue", "{authorName}'s Post": "Publication de {authorName}", "{count} comments": "{count} commentaires", - "No comments yet. Be the first to comment!": "Aucun commentaire pour le moment. Soyez le premier à commenter !", "Comment as {name}...": "Commenter en tant que {name}...", "Comment...": "Commenter...", - "Please check your internet connection and try again.": "Veuillez vérifier votre connexion internet et réessayer.", - "Please try submitting the form again.": "Veuillez réessayer de soumettre le formulaire.", "Update failed": "Échec de la mise à jour", - "That file type is not supported. Accepted types: {types}": "Ce type de fichier n'est pas pris en charge. Types acceptés : {types}", "File too large. Maximum size: {size}MB": "Fichier trop volumineux. Taille maximale : {size} Mo", - "Enter your organization's website here": "Entrez le site web de votre organisation ici", - "Is your organization seeking funding?": "Votre organisation cherche-t-elle du financement ?", - "Where can people contribute to your organization?": "Où les gens peuvent-ils contribuer à votre organisation ?", - "Add your contribution page here": "Ajoutez votre page de contribution ici", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Ajoutez un lien vers votre page de don, Open Collective, GoFundMe ou toute plateforme où les sympathisants peuvent contribuer ou en savoir plus.", - "Does your organization offer funding?": "Votre organisation offre-t-elle du financement ?", - "Are organizations currently able to apply for funding?": "Les organisations peuvent-elles actuellement postuler pour un financement ?", - "What is your funding process?": "Quel est votre processus de financement ?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Entrez une description du type de financement que vous recherchez (par ex., subventions, capital intégré, etc.)", - "Where can organizations apply?": "Où les organisations peuvent-elles postuler ?", - "Where can organizations learn more?": "Où les organisations peuvent-elles en savoir plus ?", - "Add a link where organizations can apply for funding": "Ajoutez un lien où les organisations peuvent postuler pour un financement", - "Add a link to learn more about your funding process": "Ajoutez un lien pour en savoir plus sur votre processus de financement", - "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Ajoutez un lien où d'autres peuvent en savoir plus sur la façon dont ils pourraient recevoir un financement de votre organisation maintenant ou à l'avenir.", "File too large: {name}": "Fichier trop volumineux : {name}", "Unsupported file type: {name}": "Type de fichier non pris en charge : {name}", "Navigation": "Navigation", "Log in": "Se connecter", "Helping people decide together how to use their resources": "Aider les gens à décider ensemble comment utiliser leurs ressources", "simply, intuitively, and effectively.": "simplement, intuitivement et efficacement.", - "Built for": "Conçu pour", - "communities": "communautés", - "ready to share power and co-create": "prêtes à partager le pouvoir et co-créer", - "social change": "changement social", - "and": "et", - "funders": "financeurs", - "who trust them to lead.": "qui leur font confiance pour diriger.", "No setup headaches. No learning curve.": "Aucune complication d'installation. Aucune courbe d'apprentissage.", - "Common just works, instantly, for": "Common fonctionne tout simplement, instantanément, pour", - "everyone": "tout le monde", "Trusted by": "Adopté par", "Get early access": "Obtenez un accès anticipé", "We're getting ready to welcome more organizations to Common.": "Nous nous préparons à accueillir plus d'organisations sur Common.", @@ -831,13 +795,11 @@ "Last name": "Nom de famille", "Last name here": "Nom de famille ici", "Email address": "Adresse e-mail", - "Organization": "Organisation", "Organization name": "Nom de l'organisation", "Join the waitlist": "Rejoindre la liste d'attente", "You're on the list!": "Vous êtes sur la liste !", "We can't wait to see you on Common, as an early collaborator in creating an economy that works for everyone.": "Nous avons hâte de vous voir sur Common, en tant que collaborateur précoce dans la création d'une économie qui fonctionne pour tous.", "We'll be in touch soon!": "Nous vous contacterons bientôt !", - "Done": "Terminé", "Post deleted": "Publication supprimée", "Failed to delete post": "Échec de la suppression de la publication", "Selected proposal": "Proposition sélectionnée", @@ -856,15 +818,64 @@ "Take me to Common": "Emmenez-moi sur Common", "No organizations found for this profile": "Aucune organisation trouvée pour ce profil", "Could not load organizations": "Impossible de charger les organisations", - "Results for": "Résultats pour", - "No results for": "Aucun résultat pour", "You may want to try using different keywords, checking for typos, or adjusting your filters.": "Vous pouvez essayer d'utiliser des mots-clés différents, de vérifier les fautes de frappe ou d'ajuster vos filtres.", "No {type} found.": "Aucun {type} trouvé.", "Could not load search results": "Impossible de charger les résultats de recherche", "Timeline not set": "Calendrier non défini", "Section not found": "Section introuvable", + "Join decision-making processes": "Rejoignez les processus de décision", + "Built for communities ready to share power and co-create social change — and funders who trust them to lead.": "Conçu pour les communautés prêtes à partager le pouvoir et co-créer le changement social — et les financeurs qui leur font confiance pour diriger.", + "Common just works, instantly, for everyone.": "Common fonctionne tout simplement, instantanément, pour tout le monde.", + "will now appear as a {relationship} on your profile.": "apparaîtra désormais comme {relationship} sur votre profil.", + "Added you as a {relationship}": "Vous a ajouté en tant que {relationship}", + "Results for {query}": "Résultats pour {query}", + "No results for {query}": "Aucun résultat pour {query}", + "Individuals": "Individus", + "individuals": "individus", + "organizations": "organisations", + "Edit Profile": "Modifier le profil", + "Feature Requests & Support": "Demandes de fonctionnalités et support", + "Log out": "Se déconnecter", + "Category": "Catégorie", "Participant Preview": "Aperçu du participant", "Review Proposal": "Évaluer la proposition", "pts": "pts", - "Yes/No": "Oui/Non" + "Yes/No": "Oui/Non", + "Field list": "Liste des champs", + "Hidden": "Masqué", + "Failed to update proposal status": "Échec de la mise à jour du statut de la proposition", + "Proposal shortlisted successfully": "Proposition présélectionnée avec succès", + "Proposal rejected successfully": "Proposition rejetée avec succès", + "Failed to update proposal visibility": "Échec de la mise à jour de la visibilité de la proposition", + "is now hidden from active proposals.": "est maintenant masqué des propositions actives.", + "is now visible in active proposals.": "est maintenant visible dans les propositions actives.", + "Unhide proposal": "Afficher la proposition", + "Hide proposal": "Masquer la proposition", + "Update Proposal": "Mettre à jour la proposition", + "Read full proposal": "Lire la proposition complète", + "My ballot": "Mon bulletin", + "Click to download": "Cliquez pour télécharger", + "Exporting...": "Exportation...", + "Export": "Exporter", + "Your ballot is in!": "Votre bulletin est enregistré !", + "Thank you for participating in the 2025 Community Vote. Your voice helps shape how we invest in our community.": "Merci d'avoir participé au Vote Communautaire 2025. Votre voix aide à façonner la manière dont nous investissons dans notre communauté.", + "Here's what will happen next:": "Voici ce qui va se passer ensuite :", + "View all proposals": "Voir toutes les propositions", + "Staff": "Personnel", + "Please complete the following required fields:": "Veuillez remplir les champs obligatoires suivants :", + "General": "Général", + "Setting up": "Configuration", + "Code of Conduct": "Code de conduite", + "Create Organization": "Créer une organisation", + "Too many emails": "Trop d'adresses e-mail", + "You can invite a maximum of 100 emails at once. Please reduce the number and try again.": "Vous pouvez inviter un maximum de 100 adresses e-mail à la fois. Veuillez réduire le nombre et réessayer.", + "Separate multiple emails with commas or line breaks": "Séparez les adresses e-mail par des virgules ou des sauts de ligne", + "Type emails followed by a comma or line break...": "Saisissez les adresses e-mail suivies d'une virgule ou d'un saut de ligne...", + "your organization": "votre organisation", + "Add your organization's details": "Ajoutez les détails de votre organisation", + "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "Pour l'instant, nous ne gérons que les comptes administrateurs. À l'avenir, nous serons en mesure de gérer les comptes membres.", + "Proposal Title": "Titre de la Proposition", + "Start typing...": "Commencez à taper...", + "Reason(s) and Insight(s)": "Raison(s) et aperçu(s)", + "Placeholder": "Espace réservé" } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 76f270bc7..7fc1f8c44 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -10,7 +10,6 @@ "Professional title": "Título profissional", "Enter your professional title": "Digite seu título profissional", "Continue": "Continuar", - "Add your organization's details": "Adicione os detalhes da sua organização", "We've pre-filled information about [ORGANIZATION].": "Preenchemos as informações sobre [ORGANIZATION].", "Please review and make any necessary changes.": "Por favor, revise e faça as alterações necessárias.", "Name": "Nome", @@ -40,7 +39,6 @@ "We've found your organization": "Encontramos sua organização", "join_subheader": "Com base no domínio do seu e-mail, você tem acesso para se juntar a esta organização.", "Confirm Administrator Access": "Confirmar Acesso de Administrador", - "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "Por enquanto, estamos suportando apenas contas de administrador. No futuro, poderemos suportar contas de membros.", "Get Started": "Começar", "Get Started + Add My Organization": "Começar + Adicionar Minha Organização", "Choose this if you also admin another organization": "Escolha isto se você também administra outra organização", @@ -49,7 +47,6 @@ "Something went wrong on our end. Please try again": "Algo deu errado do nosso lado. Por favor, tente novamente", "Must be at most 200 characters": "Deve ter no máximo 200 caracteres", "That file type is not supported. Accepted types: {types}": "Esse tipo de arquivo não é suportado. Tipos aceitos: {types}", - "File too large. Maximum size: {maxSizeMB}MB": "Arquivo muito grande. Tamanho máximo: {maxSizeMB}MB", "I have read and accept the": "Li e aceito os", "Terms of Use Overview": "Visão Geral dos Termos de Uso", "Privacy Policy Overview": "Visão Geral da Política de Privacidade", @@ -60,14 +57,14 @@ "What types of funding are you seeking?": "Que tipos de financiamento você está buscando?", "Where can people contribute to your organization?": "Onde as pessoas podem contribuir para sua organização?", "Add your contribution page here": "Adicione sua página de contribuição aqui", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Adicione um link para sua página de doação, Open Collective, GoFundMe ou qualquer plataforma onde apoiadores possam contribuir ou saber mais sobre como fazê-lo.", + "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Adicione um link para sua página de doações, Open Collective, GoFundMe ou qualquer plataforma onde apoiadores possam contribuir ou saber mais.", "Does your organization offer funding?": "Sua organização oferece financiamento?", - "Are organizations currently able to apply for funding?": "As organizações podem atualmente solicitar financiamento?", + "Are organizations currently able to apply for funding?": "As organizações podem atualmente se candidatar a financiamento?", "What is your funding process?": "Qual é o seu processo de financiamento?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Digite uma descrição do tipo de financiamento que você está buscando (por exemplo, subsidios ou subvenção, capital de participação, etc.)", + "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Insira uma descrição do tipo de financiamento que você busca (por exemplo, subsídios, capital integrado, etc.)", "Where can organizations apply?": "Onde as organizações podem se candidatar?", "Where can organizations learn more?": "Onde as organizações podem saber mais?", - "Add a link where organizations can apply for funding": "Adicione um link onde as organizações possam solicitar financiamento", + "Add a link where organizations can apply for funding": "Adicione um link onde as organizações possam se candidatar a financiamento", "Add a link to learn more about your funding process": "Adicione um link para saber mais sobre seu processo de financiamento", "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Adicione um link onde outros possam saber mais sobre como podem receber financiamento da sua organização agora ou no futuro.", "Enter your organization's website here": "Insira o site da sua organização aqui", @@ -91,7 +88,7 @@ "Enter a valid website address": "Digite um endereço de site válido", "What types of funding are you offering?": "Que tipos de financiamento você está oferecendo?", "Select locations": "Selecionar localizações", - "Please try submitting the form again.": "Por favor, tente enviar o formulário novamente.", + "Please try submitting the form again.": "Tente enviar o formulário novamente.", "Failed to join organization": "Falha ao entrar na organização", "Enter a name for your organization": "Digite um nome para sua organização", "Must be at most 100 characters": "Deve ter no máximo 100 caracteres", @@ -159,7 +156,7 @@ "Loading proposal...": "Carregando proposta...", "Submitted on": "Enviado em", "Loading comments...": "Carregando comentários...", - "No comments yet. Be the first to comment!": "Ainda não há comentários. Seja o primeiro a comentar!", + "No comments yet. Be the first to comment!": "Nenhum comentário ainda. Seja o primeiro a comentar!", "SHARE YOUR IDEAS.": "COMPARTILHE SUAS IDEIAS.", "YOUR BALLOT IS IN.": "SEU VOTO FOI ENVIADO.", "COMMITTEE DELIBERATION.": "DELIBERAÇÃO DO COMITÊ.", @@ -196,13 +193,12 @@ "Connection issue": "Problema de conexão", "Please try sending the invite again.": "Por favor tente enviar o convite novamente.", "No connection": "Sem conexão", - "Please check your internet connection and try again.": "Por favor verifique sua conexão com a internet e tente novamente.", + "Please check your internet connection and try again.": "Verifique sua conexão com a internet e tente novamente.", "Invalid email address": "Endereço de e-mail inválido", "Invalid email addresses": "Endereços de e-mail inválidos", "Loading roles...": "Carregando funções...", "Invalid email": "E-mail inválido", "is not a valid email address": "não é um endereço de e-mail válido", - "Type emails followed by a comma or line break...": "Digite e-mails seguidos de vírgula ou quebra de linha...", "Invite users": "Convidar usuários", "Invite others to Common": "Convidar outros para a Common", "Add to my organization": "Adicionar à minha organização", @@ -214,7 +210,6 @@ "Add to organization": "Adicionar à organização", "Role": "Função", "Invite new organizations onto Common.": "Convide novas organizações para a Common.", - "Separate multiple emails with commas or line breaks": "Separe múltiplos e-mails com vírgulas ou quebras de linha", "Personal Message": "Mensagem pessoal", "Add a personal note to your invitation": "Adicione uma nota pessoal ao seu convite", "relationship": "relacionamento", @@ -726,7 +721,6 @@ "Delete draft": "Excluir rascunho", "Decision deleted successfully": "Decisão excluída com sucesso", "Failed to delete decision": "Falha ao excluir a decisão", - "Deleting...": "Excluindo...", "A bridge to the": "Uma ponte para a", "new economy.": "nova economia.", "Connect with your network.": "Conecte-se com sua rede.", @@ -754,10 +748,7 @@ "Failed to verify code": "Falha ao verificar o código", "Relationship Requests": "Solicitações de relacionamento", "Active Decisions": "Decisões ativas", - "will now appear as a": "agora aparecerá como", "related organization": "organização relacionada", - "on your profile.": "no seu perfil.", - "Added you as a": "Adicionou você como", "Specify your funding relationship": "Especifique sua relação de financiamento", "How do your organizations support each other?": "Como suas organizações se apoiam mutuamente?", "Your organization funds {organizationName}": "Sua organização financia {organizationName}", @@ -770,44 +761,17 @@ "Select language": "Selecionar idioma", "{authorName}'s Post": "Publicação de {authorName}", "{count} comments": "{count} comentários", - "No comments yet. Be the first to comment!": "Nenhum comentário ainda. Seja o primeiro a comentar!", "Comment as {name}...": "Comentar como {name}...", "Comment...": "Comentar...", - "Please check your internet connection and try again.": "Verifique sua conexão com a internet e tente novamente.", - "Please try submitting the form again.": "Tente enviar o formulário novamente.", "Update failed": "Falha na atualização", - "That file type is not supported. Accepted types: {types}": "Esse tipo de arquivo não é suportado. Tipos aceitos: {types}", "File too large. Maximum size: {size}MB": "Arquivo muito grande. Tamanho máximo: {size}MB", - "Enter your organization's website here": "Insira o site da sua organização aqui", - "Is your organization seeking funding?": "Sua organização está buscando financiamento?", - "Where can people contribute to your organization?": "Onde as pessoas podem contribuir para sua organização?", - "Add your contribution page here": "Adicione sua página de contribuição aqui", - "Add a link to your donation page, Open Collective, GoFundMe or any platform where supporters can contribute or learn more about how.": "Adicione um link para sua página de doações, Open Collective, GoFundMe ou qualquer plataforma onde apoiadores possam contribuir ou saber mais.", - "Does your organization offer funding?": "Sua organização oferece financiamento?", - "Are organizations currently able to apply for funding?": "As organizações podem atualmente se candidatar a financiamento?", - "What is your funding process?": "Qual é o seu processo de financiamento?", - "Enter a description of the type of funding you're seeking (e.g., grants, integrated capital, etc.)": "Insira uma descrição do tipo de financiamento que você busca (por exemplo, subsídios, capital integrado, etc.)", - "Where can organizations apply?": "Onde as organizações podem se candidatar?", - "Where can organizations learn more?": "Onde as organizações podem saber mais?", - "Add a link where organizations can apply for funding": "Adicione um link onde as organizações possam se candidatar a financiamento", - "Add a link to learn more about your funding process": "Adicione um link para saber mais sobre seu processo de financiamento", - "Add a link where others can learn more about how to they might receive funding from your organization now or in the future.": "Adicione um link onde outros possam saber mais sobre como podem receber financiamento da sua organização agora ou no futuro.", "File too large: {name}": "Arquivo muito grande: {name}", "Unsupported file type: {name}": "Tipo de arquivo não suportado: {name}", "Navigation": "Navegação", "Log in": "Entrar", "Helping people decide together how to use their resources": "Ajudando pessoas a decidir juntas como usar seus recursos", "simply, intuitively, and effectively.": "de forma simples, intuitiva e eficaz.", - "Built for": "Feito para", - "communities": "comunidades", - "ready to share power and co-create": "prontas para compartilhar poder e co-criar", - "social change": "mudança social", - "and": "e", - "funders": "financiadores", - "who trust them to lead.": "que confiam nelas para liderar.", "No setup headaches. No learning curve.": "Sem dores de cabeça com configuração. Sem curva de aprendizado.", - "Common just works, instantly, for": "Common simplesmente funciona, instantaneamente, para", - "everyone": "todos", "Trusted by": "Confiado por", "Get early access": "Obtenha acesso antecipado", "We're getting ready to welcome more organizations to Common.": "Estamos nos preparando para receber mais organizações no Common.", @@ -827,13 +791,11 @@ "Last name": "Sobrenome", "Last name here": "Sobrenome aqui", "Email address": "Endereço de e-mail", - "Organization": "Organização", "Organization name": "Nome da organização", "Join the waitlist": "Entrar na lista de espera", "You're on the list!": "Você está na lista!", "We can't wait to see you on Common, as an early collaborator in creating an economy that works for everyone.": "Mal podemos esperar para vê-lo no Common, como um colaborador pioneiro na criação de uma economia que funcione para todos.", "We'll be in touch soon!": "Entraremos em contato em breve!", - "Done": "Concluído", "Post deleted": "Publicação excluída", "Failed to delete post": "Falha ao excluir a publicação", "Selected proposal": "Proposta selecionada", @@ -852,15 +814,68 @@ "Take me to Common": "Leve-me ao Common", "No organizations found for this profile": "Nenhuma organização encontrada para este perfil", "Could not load organizations": "Não foi possível carregar as organizações", - "Results for": "Resultados para", - "No results for": "Sem resultados para", "You may want to try using different keywords, checking for typos, or adjusting your filters.": "Você pode tentar usar palavras-chave diferentes, verificar erros de digitação ou ajustar seus filtros.", "No {type} found.": "Nenhum {type} encontrado.", "Could not load search results": "Não foi possível carregar os resultados da pesquisa", "Timeline not set": "Cronograma não definido", "Section not found": "Seção não encontrada", + "English": "Inglês", + "Spanish": "Espanhol", + "French": "Francês", + "Portuguese": "Português", + "Bengali": "Bengali", + "Built for communities ready to share power and co-create social change — and funders who trust them to lead.": "Feito para comunidades prontas para compartilhar poder e co-criar mudança social — e financiadores que confiam nelas para liderar.", + "Common just works, instantly, for everyone.": "Common simplesmente funciona, instantaneamente, para todos.", + "will now appear as a {relationship} on your profile.": "agora aparecerá como {relationship} no seu perfil.", + "Added you as a {relationship}": "Adicionou você como {relationship}", + "Results for {query}": "Resultados para {query}", + "No results for {query}": "Nenhum resultado para {query}", + "Individuals": "Indivíduos", + "individuals": "indivíduos", + "organizations": "organizações", + "Edit Profile": "Editar perfil", + "Feature Requests & Support": "Solicitações de funcionalidades e suporte", + "Log out": "Sair", + "Category": "Categoria", "Participant Preview": "Pré-visualização do participante", "Review Proposal": "Avaliar proposta", "pts": "pts", - "Yes/No": "Sim/Não" + "Yes/No": "Sim/Não", + "Field list": "Lista de campos", + "Hidden": "Oculto", + "Failed to update proposal status": "Falha ao atualizar o status da proposta", + "Proposal shortlisted successfully": "Proposta pré-selecionada com sucesso", + "Proposal rejected successfully": "Proposta rejeitada com sucesso", + "Failed to update proposal visibility": "Falha ao atualizar a visibilidade da proposta", + "is now hidden from active proposals.": "agora está oculto das propostas ativas.", + "is now visible in active proposals.": "agora está visível nas propostas ativas.", + "Unhide proposal": "Mostrar proposta", + "Hide proposal": "Ocultar proposta", + "Update Proposal": "Atualizar proposta", + "Read full proposal": "Ler proposta completa", + "My ballot": "Minha cédula", + "Click to download": "Clique para baixar", + "Exporting...": "Exportando...", + "Export": "Exportar", + "Your ballot is in!": "Seu voto foi registrado!", + "Thank you for participating in the 2025 Community Vote. Your voice helps shape how we invest in our community.": "Obrigado por participar da Votação Comunitária 2025. Sua voz ajuda a definir como investimos em nossa comunidade.", + "Here's what will happen next:": "Veja o que acontecerá a seguir:", + "View all proposals": "Ver todas as propostas", + "Staff": "Equipe", + "Please complete the following required fields:": "Por favor, preencha os seguintes campos obrigatórios:", + "General": "Geral", + "Setting up": "Configurando", + "Code of Conduct": "Código de conduta", + "Create Organization": "Criar organização", + "Too many emails": "Muitos e-mails", + "You can invite a maximum of 100 emails at once. Please reduce the number and try again.": "Você pode convidar no máximo 100 e-mails de uma vez. Por favor, reduza o número e tente novamente.", + "Separate multiple emails with commas or line breaks": "Separe vários e-mails com vírgulas ou quebras de linha", + "Type emails followed by a comma or line break...": "Digite e-mails seguidos de uma vírgula ou quebra de linha...", + "your organization": "sua organização", + "Add your organization's details": "Adicione os detalhes da sua organização", + "For now, we're only supporting administrator accounts. In the future, we'll be able to support member accounts.": "Por enquanto, estamos suportando apenas contas de administrador. No futuro, poderemos suportar contas de membros.", + "Proposal Title": "Título da Proposta", + "Start typing...": "Comece a digitar...", + "Reason(s) and Insight(s)": "Razão(ões) e percepção(ões)", + "Placeholder": "Espaço reservado" } diff --git a/apps/app/src/lib/i18n/routing.tsx b/apps/app/src/lib/i18n/routing.tsx index 56c689236..61d4cf409 100644 --- a/apps/app/src/lib/i18n/routing.tsx +++ b/apps/app/src/lib/i18n/routing.tsx @@ -3,9 +3,37 @@ import { cn } from '@op/ui/utils'; import { useTranslations as _useTranslations } from 'next-intl'; import { createNavigation } from 'next-intl/navigation'; import { defineRouting } from 'next-intl/routing'; -import { AnchorHTMLAttributes, useMemo } from 'react'; +import type { AnchorHTMLAttributes, ReactNode } from 'react'; +import { useMemo } from 'react'; import { i18nConfig } from './config'; +import type messages from './dictionaries/en.json'; + +/** + * Union of all known translation keys derived from the English dictionary. + * English serves as the canonical source of truth — other language dictionaries + * must contain the same keys. + */ +export type TranslationKey = keyof typeof messages; + +/** + * Typed translation function returned by `useTranslations()`. + * + * Only accepts `TranslationKey` — typos and missing keys are caught at compile + * time (no runtime enforcement). For dynamic keys (e.g. template field labels + * from the database), cast with `as TranslationKey` to bypass the check. + * + * Values are typed as optional `Record` because this custom + * interface flattens all keys into a single `TranslationKey` union, which + * discards the per-key value inference that next-intl normally provides. + */ +export interface TranslateFn { + (key: TranslationKey, values?: Record): string; + rich(key: TranslationKey, values?: Record): ReactNode; + markup(key: TranslationKey, values?: Record): string; + raw(key: TranslationKey): unknown; + has(key: TranslationKey): boolean; +} export const routing = defineRouting(i18nConfig); @@ -35,32 +63,35 @@ const Link = ({ ); }; -// periods are parsed as path separators by next-intl, so we need to replace -// them with underscores both here and in getRequestConfig -const useTranslations: typeof _useTranslations = (...args) => { - const translateFn = _useTranslations(...args); +// Periods are parsed as path separators by next-intl, so we need to replace +// them with underscores both here and in request.ts's getConfig helper. +const useTranslations = (): TranslateFn => { + const translateFn = _useTranslations(); return useMemo(() => { + function transformKey(key: string): string { + return key.replaceAll('.', '_'); + } + const proxyTranslateFn = new Proxy(translateFn, { - apply(target, thisArg, argumentsList: Parameters) { + apply(target, thisArg, argumentsList: [string, ...unknown[]]) { const [message, ...rest] = argumentsList; - const originalMessage = message; - const transformedMessage = message.replaceAll( - '.', - '_', - ) as typeof message; + const transformedMessage = transformKey(message); - const result = target.apply(thisArg, [transformedMessage, ...rest]); + const result = Reflect.apply(target, thisArg, [ + transformedMessage, + ...rest, + ]); - // If the result is the same as the transformed message, it means the key wasn't found - // In this case, return the original message with periods intact + // If next-intl returns the transformed key itself, the key wasn't found. + // Fall back to the original key so users see clean text with periods. if (result === transformedMessage) { - return originalMessage; + return message; } return result; }, - }) as typeof translateFn; + }); Reflect.ownKeys(translateFn).forEach((key) => { const propertyDescriptor = Object.getOwnPropertyDescriptor( @@ -73,7 +104,35 @@ const useTranslations: typeof _useTranslations = (...args) => { } }); - return proxyTranslateFn; + // Wrap rich(), markup(), raw(), and has() to apply dot-to-underscore transformation. + // For string-returning methods, also apply the missing-key fallback. + for (const method of ['rich', 'markup', 'raw', 'has'] as const) { + const original = (translateFn as unknown as Record)[ + method + ] as Function; + if (typeof original === 'function') { + (proxyTranslateFn as unknown as Record)[method] = ( + message: string, + ...rest: unknown[] + ) => { + const transformedMessage = transformKey(message); + const result = original.call( + translateFn, + transformedMessage, + ...rest, + ); + + // Apply missing-key fallback for string results (markup returns string) + if (typeof result === 'string' && result === transformedMessage) { + return message; + } + + return result; + }; + } + } + + return proxyTranslateFn as unknown as TranslateFn; }, [translateFn]); }; From 6f23009a75556cc3ef86fc247746c72f73884513 Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Mon, 2 Mar 2026 16:54:02 -0500 Subject: [PATCH 06/14] Fix parent container scroll bug in proposal template editor (#693) Needed to properly set overflow on the parent container. Also adjusted where we set `[scrollbar-gutter:stable]` which prevents layout shift when the scrollbar appears --- .../app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx | 4 ++-- .../stepContent/general/OverviewSectionForm.tsx | 2 +- .../stepContent/general/PhasesSectionContent.tsx | 2 +- .../stepContent/general/ProposalCategoriesSectionContent.tsx | 2 +- .../stepContent/template/TemplateEditorContent.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx index af3e36833..e8f808eb3 100644 --- a/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx +++ b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx @@ -40,7 +40,7 @@ const EditDecisionPage = async ({ }; return ( -
+
-
+
+
{ e.preventDefault(); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx index 6c93d1720..72087d1f6 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx @@ -149,7 +149,7 @@ export function PhasesSectionContent({ }; return ( -
+

{t('Phases')}

0; return ( -
+
{t('Proposal Categories')} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx index 00a1e1a21..9b1c9d0b1 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx @@ -351,7 +351,7 @@ export function TemplateEditorContent({
- + {t('Proposal template')}

From 0583ead907d9959bb23ac19dff9e2a154234475c Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 09:58:16 +0100 Subject: [PATCH 07/14] Consolidate ProcessPhase type and modernize process schema encoder (#660) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Move `ProcessPhase` type from scattered UI component definitions to a single export from `@op/api/encoders` - Replace legacy `processSchemaEncoder` with a modern phase-based format using passthrough for flexibility - Delete `apps/app/src/components/decisions/types.ts` (no longer needed) **Part 1 of 3** in the transition scheduling logic stack: 1. **This PR** — Encoder/UI type consolidation 2. `transition-scheduling-core` — Core scheduling logic 3. `transition-scheduling-tests` — Test suite ## Test plan - [ ] `pnpm typecheck` passes - [ ] `pnpm format:check` passes - [ ] No behavioral changes — pure refactoring --- .../decisions/CurrentPhaseSurface.tsx | 5 +- .../components/decisions/DecisionHeader.tsx | 2 +- .../decisions/DecisionProcessStepper.tsx | 13 +- .../components/decisions/DecisionStats.tsx | 11 +- apps/app/src/components/decisions/types.ts | 14 -- services/api/src/encoders/decision.ts | 148 ++++++++---------- 6 files changed, 69 insertions(+), 124 deletions(-) delete mode 100644 apps/app/src/components/decisions/types.ts diff --git a/apps/app/src/components/decisions/CurrentPhaseSurface.tsx b/apps/app/src/components/decisions/CurrentPhaseSurface.tsx index e6b8d06e6..36e724075 100644 --- a/apps/app/src/components/decisions/CurrentPhaseSurface.tsx +++ b/apps/app/src/components/decisions/CurrentPhaseSurface.tsx @@ -5,15 +5,12 @@ import { formatCurrency, formatDateRange, } from '@/utils/formatting'; -import type { processPhaseSchema } from '@op/api/encoders'; +import { type ProcessPhase } from '@op/api/encoders'; import { Surface } from '@op/ui/Surface'; import { useLocale } from 'next-intl'; -import type { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; -type ProcessPhase = z.infer; - interface CurrentPhaseSurfaceProps { currentPhase?: ProcessPhase; budget?: number; diff --git a/apps/app/src/components/decisions/DecisionHeader.tsx b/apps/app/src/components/decisions/DecisionHeader.tsx index a1d6cbaaa..f669d03d2 100644 --- a/apps/app/src/components/decisions/DecisionHeader.tsx +++ b/apps/app/src/components/decisions/DecisionHeader.tsx @@ -1,3 +1,4 @@ +import { type ProcessPhase } from '@op/api/encoders'; import { createClient } from '@op/api/serverClient'; import type { DecisionInstanceData } from '@op/common'; import { cn } from '@op/ui/utils'; @@ -6,7 +7,6 @@ import { ReactNode } from 'react'; import { DecisionInstanceHeader } from '@/components/decisions/DecisionInstanceHeader'; import { DecisionProcessStepper } from '@/components/decisions/DecisionProcessStepper'; -import { ProcessPhase } from '@/components/decisions/types'; interface DecisionHeaderProps { instanceId: string; diff --git a/apps/app/src/components/decisions/DecisionProcessStepper.tsx b/apps/app/src/components/decisions/DecisionProcessStepper.tsx index 99a75c87c..0f45f5234 100644 --- a/apps/app/src/components/decisions/DecisionProcessStepper.tsx +++ b/apps/app/src/components/decisions/DecisionProcessStepper.tsx @@ -1,19 +1,8 @@ 'use client'; +import { type ProcessPhase } from '@op/api/encoders'; import { type Phase, PhaseStepper } from '@op/ui/PhaseStepper'; -interface ProcessPhase { - id: string; - name: string; - description?: string; - phase?: { - startDate?: string; - endDate?: string; - sortOrder?: number; - }; - type?: 'initial' | 'intermediate' | 'final'; -} - interface DecisionProcessStepperProps { phases: ProcessPhase[]; currentStateId: string; diff --git a/apps/app/src/components/decisions/DecisionStats.tsx b/apps/app/src/components/decisions/DecisionStats.tsx index 89d8bb176..ed03d2f5b 100644 --- a/apps/app/src/components/decisions/DecisionStats.tsx +++ b/apps/app/src/components/decisions/DecisionStats.tsx @@ -1,13 +1,10 @@ 'use client'; import { formatCurrency, formatDateRange } from '@/utils/formatting'; -import type { processPhaseSchema } from '@op/api/encoders'; -import type { z } from 'zod'; +import { type ProcessPhase } from '@op/api/encoders'; import { useTranslations } from '@/lib/i18n'; -type ProcessPhase = z.infer; - interface DecisionStatsProps { currentPhase?: ProcessPhase; budget?: number; @@ -32,11 +29,11 @@ export function DecisionStats({

{currentPhase?.name || t('Proposal Submissions')}

- {currentPhase?.phase && ( + {(currentPhase?.phase?.startDate || currentPhase?.phase?.endDate) && (

{formatDateRange( - currentPhase.phase.startDate, - currentPhase.phase.endDate, + currentPhase.phase?.startDate, + currentPhase.phase?.endDate, ) || t('Timeline not set')}

)} diff --git a/apps/app/src/components/decisions/types.ts b/apps/app/src/components/decisions/types.ts deleted file mode 100644 index b27319a55..000000000 --- a/apps/app/src/components/decisions/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface ProcessPhase { - id: string; - name: string; - description?: string; - phase?: { - startDate?: string; - endDate?: string; - sortOrder?: number; - }; - type?: 'initial' | 'intermediate' | 'final'; - config?: { - allowProposals?: boolean; - }; -} diff --git a/services/api/src/encoders/decision.ts b/services/api/src/encoders/decision.ts index 39f44697a..b439db543 100644 --- a/services/api/src/encoders/decision.ts +++ b/services/api/src/encoders/decision.ts @@ -19,11 +19,37 @@ import { baseProfileEncoder } from './profiles'; // JSON Schema types const jsonSchemaEncoder = z.record(z.string(), z.unknown()); +// ============================================================================ +// ProcessPhase encoder (for frontend UI components) +// ============================================================================ + +/** Process phase encoder for UI display (stepper, stats, etc.) */ +export const processPhaseSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + phase: z + .object({ + startDate: z.string().optional(), + endDate: z.string().optional(), + sortOrder: z.number().optional(), + }) + .optional(), + type: z.enum(['initial', 'intermediate', 'final']).optional(), + config: z + .object({ + allowProposals: z.boolean().optional(), + }) + .optional(), +}); + +export type ProcessPhase = z.infer; + // ============================================================================ // DecisionSchemaDefinition format encoders // ============================================================================ -/** Phase behavior rules */ +/** Phase behavior rules */ const phaseRulesEncoder = z.object({ proposals: z .object({ @@ -130,7 +156,7 @@ export const decisionProcessWithSchemaEncoder = createSelectSchema( createdBy: baseProfileEncoder.optional(), }); -/** List encoder for decision processes with new schema format */ +/** List encoder for decision processes */ export const decisionProcessWithSchemaListEncoder = z.object({ processes: z.array(decisionProcessWithSchemaEncoder), total: z.number(), @@ -243,90 +269,40 @@ export const decisionProfileWithSchemaFilterSchema = z.object({ stewardProfileId: z.uuid().optional(), }); -// ============================================================================ -// Legacy format encoders (for backwards compatibility) -// ============================================================================ - -// Shared process phase schema -export const processPhaseSchema = z.object({ - id: z.string(), - name: z.string(), - description: z.string().optional(), - phase: z - .object({ - startDate: z.string().optional(), - endDate: z.string().optional(), - sortOrder: z.number().optional(), - }) - .optional(), - type: z.enum(['initial', 'intermediate', 'final']).optional(), -}); - -// Process Schema Encoder -const processSchemaEncoder = z.object({ - name: z.string(), - description: z.string().optional(), - budget: z.number().optional(), - fields: jsonSchemaEncoder.optional(), - states: z.array( - processPhaseSchema.extend({ - fields: jsonSchemaEncoder.optional(), - config: z - .object({ - allowProposals: z.boolean().optional(), - allowDecisions: z.boolean().optional(), - visibleComponents: z.array(z.string()).optional(), - }) - .optional(), - }), - ), - transitions: z.array( - z.object({ - id: z.string(), - name: z.string(), - from: z.union([z.string(), z.array(z.string())]), - to: z.string(), - rules: z - .object({ - type: z.enum(['manual', 'automatic']), - conditions: z - .array( - z.object({ - type: z.enum([ - 'time', - 'proposalCount', - 'participationCount', - 'approvalRate', - 'customField', - ]), - operator: z.enum([ - 'equals', - 'greaterThan', - 'lessThan', - 'between', - ]), - value: z.unknown().optional(), - field: z.string().optional(), - }), - ) - .optional(), - requireAll: z.boolean().optional(), - }) - .optional(), - actions: z - .array( - z.object({ - type: z.enum(['notify', 'updateField', 'createRecord']), - config: z.record(z.string(), z.unknown()), - }), - ) - .optional(), - }), - ), - initialState: z.string(), - decisionDefinition: jsonSchemaEncoder, - proposalTemplate: jsonSchemaEncoder, -}); +// ============================================================================= +// Process Schema Encoder (new format with passthrough for flexibility) +// ============================================================================= +const processSchemaEncoder = z + .object({ + name: z.string(), + description: z.string().optional(), + id: z.string().optional(), + version: z.string().optional(), + config: z + .object({ + hideBudget: z.boolean().optional(), + }) + .passthrough() + .optional(), + phases: z + .array( + z + .object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + rules: phaseRulesEncoder.optional(), + selectionPipeline: selectionPipelineEncoder.optional(), + settings: jsonSchemaEncoder.optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + }) + .passthrough(), + ) + .optional(), + proposalTemplate: jsonSchemaEncoder.optional(), + }) + .passthrough(); // Instance Data Encoder that supports both new and legacy field names const instanceDataEncoder = z.preprocess( From 6715db3e96f1398332836b0c65fa1a2ff9715d3e Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 21:54:27 +0100 Subject: [PATCH 08/14] Button loading state in @op/ui (#704) Adds a loading state for the Button component in @op/ui so we can have a consistent loading state experience. Also ensures that loading states don't change the size of the Button itself. https://apps-workshop-git-button-loading-state-oneproject.vercel.app/?path=/docs/button--docs Screenshot 2026-03-03 at 17 11 00 --- .../ActiveDecisionsNotifications/index.tsx | 4 +- packages/ui/src/components/Button.tsx | 77 +++++++++++++-- packages/ui/stories/Button.stories.tsx | 31 ++++++ packages/ui/stories/ButtonLink.stories.tsx | 98 +++++++++++++++++++ 4 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 packages/ui/stories/ButtonLink.stories.tsx diff --git a/apps/app/src/components/ActiveDecisionsNotifications/index.tsx b/apps/app/src/components/ActiveDecisionsNotifications/index.tsx index ac647f912..3b60fa38a 100644 --- a/apps/app/src/components/ActiveDecisionsNotifications/index.tsx +++ b/apps/app/src/components/ActiveDecisionsNotifications/index.tsx @@ -4,7 +4,6 @@ import { trpc } from '@op/api/client'; import { ProcessStatus } from '@op/api/encoders'; import { getTextPreview } from '@op/core'; import { ButtonLink } from '@op/ui/Button'; -import { LoadingSpinner } from '@op/ui/LoadingSpinner'; import { NotificationPanel, NotificationPanelActions, @@ -62,8 +61,9 @@ const ActiveDecisionsNotificationsSuspense = () => { className="w-full sm:w-auto" href={`/decisions/${decision.slug}`} onPress={() => setNavigatingId(decision.id)} + isLoading={isNavigating} > - {isNavigating ? : t('Participate')} + {t('Participate')} diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 01e99de1d..c9c661bbc 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -4,6 +4,8 @@ import { Button as RACButton, Link as RACLink } from 'react-aria-components'; import { tv } from 'tailwind-variants'; import type { VariantProps } from 'tailwind-variants'; +import { cn } from '../lib/utils'; +import { LoadingSpinner } from './LoadingSpinner'; import { Tooltip, TooltipTrigger } from './Tooltip'; import type { TooltipProps, TooltipTriggerProps } from './Tooltip'; @@ -93,16 +95,46 @@ export interface ButtonProps extends React.ComponentProps, ButtonVariants { className?: string; + isLoading?: boolean; } export const Button = (props: ButtonProps) => { - const { unstyled, ...rest } = props; + const { unstyled, isLoading, ...rest } = props; + + const className = unstyled + ? props.className + : buttonStyle({ + ...props, + isDisabled: isLoading ? false : props.isDisabled, + className: isLoading + ? cn(props.className, 'relative') + : props.className, + }); + + if (!isLoading) { + return ; + } + + const { children, ...buttonRest } = rest; return ( - + + {(renderProps) => ( + <> + + {typeof children === 'function' ? children(renderProps) : children} + + + + + + )} + ); }; @@ -110,10 +142,43 @@ export interface ButtonLinkProps extends React.ComponentProps, ButtonVariants { className?: string; + isLoading?: boolean; } export const ButtonLink = (props: ButtonLinkProps) => { - return ; + const { isLoading, ...rest } = props; + + const className = buttonStyle({ + ...props, + isDisabled: isLoading ? false : props.isDisabled, + className: isLoading ? cn(props.className, 'relative') : props.className, + }); + + if (!isLoading) { + return ; + } + + const { children, ...linkRest } = rest; + + return ( + + {(renderProps) => ( + <> + + {typeof children === 'function' ? children(renderProps) : children} + + + + + + )} + + ); }; export interface ButtonTooltipProps extends ButtonProps { diff --git a/packages/ui/stories/Button.stories.tsx b/packages/ui/stories/Button.stories.tsx index 0cb538598..7de443333 100644 --- a/packages/ui/stories/Button.stories.tsx +++ b/packages/ui/stories/Button.stories.tsx @@ -15,9 +15,13 @@ export default { unstyled: { control: 'boolean', }, + isLoading: { + control: 'boolean', + }, }, args: { isDisabled: false, + isLoading: false, children: 'Button', }, }; @@ -39,6 +43,20 @@ export const Example = () => ( + Loading: + + + + +
); @@ -59,3 +77,16 @@ export const Destructive = { color: 'destructive', }, }; + +export const Loading = { + args: { + isLoading: true, + }, +}; + +export const LoadingSmall = { + args: { + isLoading: true, + size: 'small', + }, +}; diff --git a/packages/ui/stories/ButtonLink.stories.tsx b/packages/ui/stories/ButtonLink.stories.tsx new file mode 100644 index 000000000..e6b673171 --- /dev/null +++ b/packages/ui/stories/ButtonLink.stories.tsx @@ -0,0 +1,98 @@ +import { ButtonLink } from '../src/components/Button'; + +export default { + title: 'ButtonLink', + component: ButtonLink, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + argTypes: { + color: { + control: 'select', + options: ['primary', 'secondary', 'gradient', 'destructive'], + }, + isLoading: { + control: 'boolean', + }, + }, + args: { + isDisabled: false, + isLoading: false, + children: 'ButtonLink', + href: '#', + }, +}; + +export const Example = () => ( +
+ Medium: + ButtonLink + + ButtonLink + + + ButtonLink + + + ButtonLink + + Small: + + ButtonLink + + + ButtonLink + + + ButtonLink + + Loading: + + ButtonLink + + + ButtonLink + + + ButtonLink + + + ButtonLink + + + ButtonLink + +
+); + +export const Primary = { + args: { + color: 'primary', + }, +}; + +export const Secondary = { + args: { + color: 'secondary', + }, +}; + +export const Destructive = { + args: { + color: 'destructive', + }, +}; + +export const Loading = { + args: { + isLoading: true, + }, +}; + +export const LoadingSmall = { + args: { + isLoading: true, + size: 'small', + }, +}; From ab852f6d5c16bdd0af5d069a1dbc754422585fab Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 21:55:10 +0100 Subject: [PATCH 09/14] Proposal Template Titles and Listing Translations (#702) Support translations in the proposal listing and update the proposal template fields to support translations as well. A follow up PR will enable translations for english-speakers as well. ### AFTER Screenshot 2026-03-03 at 11 18 10 Screenshot 2026-03-03 at 11 26 59 ### BEFORE Screenshot 2026-03-03 at 11 29 05 Screenshot 2026-03-03 at 11 28 45 --- .../ProposalCard/ProposalCardComponents.tsx | 32 ++- .../decisions/ProposalContentRenderer.tsx | 28 +- .../decisions/ProposalTranslationContext.tsx | 34 +++ .../src/components/decisions/ProposalView.tsx | 13 +- .../components/decisions/ProposalsList.tsx | 113 +++++++- packages/common/src/client.ts | 1 + .../common/src/services/translation/index.ts | 2 + .../translation/parseTranslatedMeta.ts | 23 ++ .../services/translation/runTranslateBatch.ts | 29 ++ .../services/translation/translateProposal.ts | 50 ++-- .../translation/translateProposals.ts | 204 +++++++++++++ services/api/src/routers/translation/index.ts | 6 +- .../translation/translateProposal.test.ts | 189 ++++++++++++ .../translation/translateProposals.test.ts | 269 ++++++++++++++++++ .../routers/translation/translateProposals.ts | 35 +++ 15 files changed, 982 insertions(+), 46 deletions(-) create mode 100644 apps/app/src/components/decisions/ProposalTranslationContext.tsx create mode 100644 packages/common/src/services/translation/parseTranslatedMeta.ts create mode 100644 packages/common/src/services/translation/runTranslateBatch.ts create mode 100644 packages/common/src/services/translation/translateProposals.ts create mode 100644 services/api/src/routers/translation/translateProposals.test.ts create mode 100644 services/api/src/routers/translation/translateProposals.ts diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 1a03af24c..8c99c8534 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -27,6 +27,7 @@ import { Link } from '@/lib/i18n/routing'; import { Bullet } from '../../Bullet'; import { DocumentNotAvailable } from '../DocumentNotAvailable'; +import { useCardTranslation } from '../ProposalTranslationContext'; import { getProposalContentPreview } from '../proposalContentUtils'; export type Proposal = z.infer; @@ -115,9 +116,10 @@ export function ProposalCardTitle({ className?: string; }) { const t = useTranslations(); + const cardTranslation = useCardTranslation(proposal.profileId); const { title } = parseProposalData(proposal.proposalData); - const titleText = title || t('Untitled Proposal'); + const titleText = cardTranslation?.title ?? (title || t('Untitled Proposal')); const titleClasses = 'max-w-full truncate text-nowrap font-serif !text-title-sm text-neutral-black'; @@ -259,9 +261,11 @@ export function ProposalCardCategory({ }: BaseProposalCardProps & { className?: string; }) { + const cardTranslation = useCardTranslation(proposal.profileId); const { category } = parseProposalData(proposal.proposalData); + const displayCategory = cardTranslation?.category ?? category; - if (!category || !proposal.submittedBy) { + if (!displayCategory || !proposal.submittedBy) { return null; } @@ -274,7 +278,7 @@ export function ProposalCardCategory({ className, )} > - {category} + {displayCategory} ); @@ -356,16 +360,24 @@ export function ProposalCardPreview({ }: BaseProposalCardProps & { className?: string; }) { - const previewText = getProposalContentPreview( - proposal.documentContent, - (proposal.proposalTemplate as ProposalTemplateSchema) ?? undefined, - ); + const cardTranslation = useCardTranslation(proposal.profileId); + const translatedPreview = cardTranslation?.preview; + + const previewText = + translatedPreview === undefined + ? getProposalContentPreview( + proposal.documentContent, + (proposal.proposalTemplate as ProposalTemplateSchema) ?? undefined, + ) + : undefined; + + const displayText = translatedPreview ?? previewText; - if (previewText === null) { + if (displayText === null) { return ; } - if (!previewText) { + if (!displayText) { return null; } @@ -376,7 +388,7 @@ export function ProposalCardPreview({ className, )} > - {previewText} + {displayText}

); } diff --git a/apps/app/src/components/decisions/ProposalContentRenderer.tsx b/apps/app/src/components/decisions/ProposalContentRenderer.tsx index bd039c39e..8ac6836c8 100644 --- a/apps/app/src/components/decisions/ProposalContentRenderer.tsx +++ b/apps/app/src/components/decisions/ProposalContentRenderer.tsx @@ -13,6 +13,12 @@ interface ProposalContentRendererProps { proposalTemplate: ProposalTemplateSchema; /** Pre-rendered HTML per fragment key (from getProposal). */ htmlContent?: Record; + /** Optional translated field titles, descriptions, and option labels keyed by field key. */ + translatedMeta?: { + fieldTitles: Record; + fieldDescriptions: Record; + optionLabels: Record>; + } | null; } /** @@ -26,6 +32,7 @@ interface ProposalContentRendererProps { export function ProposalContentRenderer({ proposalTemplate, htmlContent, + translatedMeta, }: ProposalContentRendererProps) { const dynamicFields = useMemo(() => { if (!proposalTemplate) { @@ -45,6 +52,8 @@ export function ProposalContentRenderer({ key={field.key} field={field} html={htmlContent?.[field.key]} + translatedTitle={translatedMeta?.fieldTitles[field.key]} + translatedDescription={translatedMeta?.fieldDescriptions[field.key]} /> ))}
@@ -58,25 +67,30 @@ export function ProposalContentRenderer({ function ViewField({ field, html, + translatedTitle, + translatedDescription, }: { field: FieldDescriptor; html: string | undefined; + translatedTitle?: string; + translatedDescription?: string; }) { const { schema } = field; + const title = translatedTitle ?? schema.title; + const description = translatedDescription ?? schema.description; + return (
- {(schema.title || schema.description) && ( + {(title || description) && (
- {schema.title && ( + {title && ( - {schema.title} + {title} )} - {schema.description && ( -

- {schema.description} -

+ {description && ( +

{description}

)}
)} diff --git a/apps/app/src/components/decisions/ProposalTranslationContext.tsx b/apps/app/src/components/decisions/ProposalTranslationContext.tsx new file mode 100644 index 000000000..2dde15df6 --- /dev/null +++ b/apps/app/src/components/decisions/ProposalTranslationContext.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { type ReactNode, createContext, useContext } from 'react'; + +type TranslationRecord = Record< + string, + { title?: string; category?: string; preview?: string } +>; + +const ProposalTranslationContext = createContext( + null, +); + +export function ProposalTranslationProvider({ + translations, + children, +}: { + translations: TranslationRecord; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useCardTranslation(profileId: string | null | undefined) { + const translations = useContext(ProposalTranslationContext); + if (!translations || !profileId) { + return undefined; + } + return translations[profileId]; +} diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index 903c68c58..092eaf587 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -6,7 +6,7 @@ import { useUser } from '@/utils/UserProvider'; import { formatCurrency, formatDate } from '@/utils/formatting'; import type { RouterOutput } from '@op/api'; import { trpc } from '@op/api/client'; -import { parseProposalData } from '@op/common/client'; +import { parseProposalData, parseTranslatedMeta } from '@op/common/client'; import type { SupportedLocale } from '@op/common/client'; import type { ProposalTemplateSchema } from '@op/common/client'; import { Avatar } from '@op/ui/Avatar'; @@ -17,7 +17,7 @@ import { Tag, TagGroup } from '@op/ui/TagGroup'; import { Heart, MessageCircle } from 'lucide-react'; import { useLocale } from 'next-intl'; import Image from 'next/image'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { LuBookmark } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -158,6 +158,14 @@ export function ProposalView({ const proposalTemplate = (currentProposal.proposalTemplate as ProposalTemplateSchema) ?? null; + const translatedMeta = useMemo( + () => + translatedHtmlContent + ? parseTranslatedMeta(translatedHtmlContent.translated) + : null, + [translatedHtmlContent], + ); + // Legacy proposals store HTML under a single "default" key with no collab doc. // Render them directly instead of going through the template-driven renderer. const legacyHtml = resolvedHtmlContent?.default as string | undefined; @@ -309,6 +317,7 @@ export function ProposalView({ ) : ( diff --git a/apps/app/src/components/decisions/ProposalsList.tsx b/apps/app/src/components/decisions/ProposalsList.tsx index 9a6610dd9..0201b91f0 100644 --- a/apps/app/src/components/decisions/ProposalsList.tsx +++ b/apps/app/src/components/decisions/ProposalsList.tsx @@ -7,17 +7,20 @@ import { ProposalStatus, type proposalEncoder, } from '@op/api/encoders'; +import { SUPPORTED_LOCALES, type SupportedLocale } from '@op/common/client'; import { match } from '@op/core'; import { Button, ButtonLink } from '@op/ui/Button'; import { Checkbox } from '@op/ui/Checkbox'; import { Dialog, DialogTrigger } from '@op/ui/Dialog'; import { EmptyState } from '@op/ui/EmptyState'; import { Header3 } from '@op/ui/Header'; +import { Link } from '@op/ui/Link'; import { Modal } from '@op/ui/Modal'; import { Skeleton } from '@op/ui/Skeleton'; import { Surface } from '@op/ui/Surface'; +import { useLocale } from 'next-intl'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { LuArrowDownToLine, LuLeaf } from 'react-icons/lu'; import type { z } from 'zod'; @@ -36,7 +39,9 @@ import { ProposalCardOwnerActions, ProposalCardPreview, } from './ProposalCard'; +import { ProposalTranslationProvider } from './ProposalTranslationContext'; import { ResponsiveSelect } from './ResponsiveSelect'; +import { TranslateBanner } from './TranslateBanner'; import { VoteSubmissionModal } from './VoteSubmissionModal'; import { VoteSuccessModal } from './VoteSuccessModal'; import { VotingProposalCard } from './VotingProposalCard'; @@ -548,6 +553,68 @@ export const ProposalsList = ({ const { proposals: allProposals, canManageProposals = false } = proposalsData ?? {}; + // --- Translation state --- + const locale = useLocale(); + const supportedLocale = (SUPPORTED_LOCALES as readonly string[]).includes( + locale, + ) + ? (locale as SupportedLocale) + : null; + const [bannerDismissed, setBannerDismissed] = useState(false); + const [translationState, setTranslationState] = useState<{ + translations: Record< + string, + { title?: string; category?: string; preview?: string } + >; + sourceLocale: string; + } | null>(null); + + const translateBatchMutation = + trpc.translation.translateProposals.useMutation({ + onSuccess: (data) => { + setTranslationState({ + translations: data.translations, + sourceLocale: data.sourceLocale, + }); + }, + }); + + const handleTranslate = useCallback(() => { + if (!supportedLocale) { + return; + } + const profileIds = allProposals?.map((p) => p.profileId); + if (!profileIds?.length) { + return; + } + translateBatchMutation.mutate({ + profileIds, + targetLocale: supportedLocale, + }); + }, [translateBatchMutation, allProposals, supportedLocale]); + + const handleViewOriginal = useCallback(() => setTranslationState(null), []); + + const languageNames = useMemo( + () => new Intl.DisplayNames([locale], { type: 'language' }), + [locale], + ); + const getLanguageName = (langCode: string) => + languageNames.of(langCode) ?? langCode; + + const sourceLanguageName = translationState + ? getLanguageName( + translationState.sourceLocale.toLowerCase().split('-')[0] ?? '', + ) + : ''; + const targetLanguageName = getLanguageName(locale); + + const showBanner = + !!supportedLocale && + supportedLocale !== 'en' && + !bannerDismissed && + !translationState; + // Use the custom hook for filtering proposals const { filteredProposals: proposals, @@ -686,15 +753,41 @@ export const ProposalsList = ({
- + {/* Translation attribution */} + {translationState && ( +

+ {t('Translated from {language}', { + language: sourceLanguageName, + })}{' '} + ·{' '} + + {t('View original')} + +

+ )} + + + + + + {showBanner && ( + setBannerDismissed(true)} + isTranslating={translateBatchMutation.isPending} + languageName={targetLanguageName} + /> + )}
); }; diff --git a/packages/common/src/client.ts b/packages/common/src/client.ts index 449d74a82..36feca2cc 100644 --- a/packages/common/src/client.ts +++ b/packages/common/src/client.ts @@ -25,6 +25,7 @@ export { LOCALE_TO_DEEPL, } from './services/translation/locales'; export type { SupportedLocale } from './services/translation/locales'; +export { parseTranslatedMeta } from './services/translation/parseTranslatedMeta'; const LOGIN_PATH_RE = /^\/(?:[a-z]{2}\/)?login(\/|$|\?)/; diff --git a/packages/common/src/services/translation/index.ts b/packages/common/src/services/translation/index.ts index b30259980..cd3791561 100644 --- a/packages/common/src/services/translation/index.ts +++ b/packages/common/src/services/translation/index.ts @@ -1,3 +1,5 @@ +export { parseTranslatedMeta } from './parseTranslatedMeta'; export { translateProposal } from './translateProposal'; +export { translateProposals } from './translateProposals'; export { SUPPORTED_LOCALES, LOCALE_TO_DEEPL } from './locales'; export type { SupportedLocale } from './locales'; diff --git a/packages/common/src/services/translation/parseTranslatedMeta.ts b/packages/common/src/services/translation/parseTranslatedMeta.ts new file mode 100644 index 000000000..834001549 --- /dev/null +++ b/packages/common/src/services/translation/parseTranslatedMeta.ts @@ -0,0 +1,23 @@ +export function parseTranslatedMeta(translated: Record) { + const fieldTitles: Record = {}; + const fieldDescriptions: Record = {}; + const optionLabels: Record> = {}; + + for (const [key, value] of Object.entries(translated)) { + if (key.startsWith('field_title:')) { + fieldTitles[key.slice('field_title:'.length)] = value; + } else if (key.startsWith('field_desc:')) { + fieldDescriptions[key.slice('field_desc:'.length)] = value; + } else if (key.startsWith('option:')) { + const rest = key.slice('option:'.length); + const colonIdx = rest.indexOf(':'); + if (colonIdx !== -1) { + const fieldKey = rest.slice(0, colonIdx); + const optionValue = rest.slice(colonIdx + 1); + (optionLabels[fieldKey] ??= {})[optionValue] = value; + } + } + } + + return { fieldTitles, fieldDescriptions, optionLabels }; +} diff --git a/packages/common/src/services/translation/runTranslateBatch.ts b/packages/common/src/services/translation/runTranslateBatch.ts new file mode 100644 index 000000000..f49b1c4a7 --- /dev/null +++ b/packages/common/src/services/translation/runTranslateBatch.ts @@ -0,0 +1,29 @@ +import type { TranslatableEntry, TranslationResult } from '@op/translation'; +import { translateBatch } from '@op/translation'; +import { DeepLClient } from 'deepl-node'; + +import { CommonError } from '../../utils'; +import { LOCALE_TO_DEEPL } from './locales'; +import type { SupportedLocale } from './locales'; + +/** + * Shared helper that validates the DeepL API key, builds a client, and runs + * a batch translation. Both `translateProposal` and `translateProposals` + * delegate here so the key-check + client construction isn't duplicated. + */ +export async function runTranslateBatch( + entries: TranslatableEntry[], + targetLocale: SupportedLocale, +): Promise { + const apiKey = process.env.DEEPL_API_KEY; + if (!apiKey) { + throw new CommonError('DEEPL_API_KEY is not configured'); + } + + const client = new DeepLClient(apiKey); + return translateBatch({ + entries, + targetLocale: LOCALE_TO_DEEPL[targetLocale], + client, + }); +} diff --git a/packages/common/src/services/translation/translateProposal.ts b/packages/common/src/services/translation/translateProposal.ts index 8e9c5dcbc..c267f15fa 100644 --- a/packages/common/src/services/translation/translateProposal.ts +++ b/packages/common/src/services/translation/translateProposal.ts @@ -1,12 +1,11 @@ import type { User } from '@op/supabase/lib'; import type { TranslatableEntry } from '@op/translation'; -import { translateBatch } from '@op/translation'; -import { DeepLClient } from 'deepl-node'; -import { CommonError } from '../../utils'; import { getProposal } from '../decision/getProposal'; -import { LOCALE_TO_DEEPL } from './locales'; +import { parseSchemaOptions } from '../decision/proposalDataSchema'; +import type { ProposalTemplateSchema } from '../decision/types'; import type { SupportedLocale } from './locales'; +import { runTranslateBatch } from './runTranslateBatch'; /** * Translates a proposal's content (title, category, HTML fragments) into the @@ -61,23 +60,42 @@ export async function translateProposal({ } } + // Template field titles and descriptions + const template = proposal.proposalTemplate as ProposalTemplateSchema | null; + if (template?.properties) { + for (const [fieldKey, property] of Object.entries(template.properties)) { + if (property.title) { + entries.push({ + contentKey: `proposal:${proposalId}:field_title:${fieldKey}`, + text: property.title, + }); + } + if (property.description) { + entries.push({ + contentKey: `proposal:${proposalId}:field_desc:${fieldKey}`, + text: property.description, + }); + } + + // Dropdown option labels (oneOf or legacy enum) + const options = parseSchemaOptions(property); + for (const option of options) { + if (option.title) { + entries.push({ + contentKey: `proposal:${proposalId}:option:${fieldKey}:${option.value}`, + text: option.title, + }); + } + } + } + } + if (entries.length === 0) { return { translated: {}, sourceLocale: '', targetLocale }; } // 3. Translate via DeepL with cache-through - const apiKey = process.env.DEEPL_API_KEY; - if (!apiKey) { - throw new CommonError('DEEPL_API_KEY is not configured'); - } - - const deeplTargetCode = LOCALE_TO_DEEPL[targetLocale]; - const client = new DeepLClient(apiKey); - const results = await translateBatch({ - entries, - targetLocale: deeplTargetCode, - client, - }); + const results = await runTranslateBatch(entries, targetLocale); // 4. Build response — strip the "proposal::" prefix to get the field name back const prefix = `proposal:${proposalId}:`; diff --git a/packages/common/src/services/translation/translateProposals.ts b/packages/common/src/services/translation/translateProposals.ts new file mode 100644 index 000000000..d7d61e990 --- /dev/null +++ b/packages/common/src/services/translation/translateProposals.ts @@ -0,0 +1,204 @@ +import { getTextPreview } from '@op/core'; +import { db } from '@op/db/client'; +import type { User } from '@op/supabase/lib'; +import type { TranslatableEntry } from '@op/translation'; +import { permission } from 'access-zones'; + +import { assertInstanceProfileAccess } from '../access'; +import { generateProposalHtml } from '../decision/generateProposalHtml'; +import { getProposalDocumentsContent } from '../decision/getProposalDocumentsContent'; +import { resolveProposalTemplate } from '../decision/resolveProposalTemplate'; +import type { SupportedLocale } from './locales'; +import { runTranslateBatch } from './runTranslateBatch'; + +/** + * Translates proposal card-level content (title, category, preview text) for + * a batch of proposals in a single DeepL call. + */ +export async function translateProposals({ + profileIds, + targetLocale, + user, +}: { + profileIds: string[]; + targetLocale: SupportedLocale; + user: User; +}): Promise<{ + translations: Record< + string, + { title?: string; category?: string; preview?: string } + >; + sourceLocale: string; + targetLocale: SupportedLocale; +}> { + // 1. Bulk-fetch only the columns we need + processInstance relation + const proposals = await db.query.proposals.findMany({ + where: { profileId: { in: profileIds } }, + columns: { + id: true, + profileId: true, + proposalData: true, + }, + with: { + processInstance: { + columns: { + profileId: true, + ownerProfileId: true, + instanceData: true, + processId: true, + }, + }, + }, + }); + + if (proposals.length === 0) { + return { translations: {}, sourceLocale: '', targetLocale }; + } + + // 2. Deduplicate process instances and assert read access + resolve templates + const uniqueProcesses = new Map< + string, + { + profileId: string | null; + ownerProfileId: string | null; + instanceData: unknown; + processId: string; + } + >(); + for (const p of proposals) { + if (!uniqueProcesses.has(p.processInstance.processId)) { + uniqueProcesses.set(p.processInstance.processId, { + profileId: p.processInstance.profileId, + ownerProfileId: p.processInstance.ownerProfileId, + instanceData: p.processInstance.instanceData, + processId: p.processInstance.processId, + }); + } + } + + const templateByProcessId = new Map< + string, + Awaited> + >(); + await Promise.all( + [...uniqueProcesses.values()].map(async (instance) => { + // Assert the user has decisions:READ on each unique process instance + await assertInstanceProfileAccess({ + user: { id: user.id }, + instance, + profilePermissions: [ + { decisions: permission.READ }, + { decisions: permission.ADMIN }, + ], + orgFallbackPermissions: [ + { decisions: permission.READ }, + { decisions: permission.ADMIN }, + ], + }); + + const template = await resolveProposalTemplate( + instance.instanceData as Record | null, + instance.processId, + ); + templateByProcessId.set(instance.processId, template); + }), + ); + + // 3. Batch document fetch + const documentContentMap = await getProposalDocumentsContent( + proposals.map((p) => ({ + id: p.id, + proposalData: p.proposalData, + proposalTemplate: + templateByProcessId.get(p.processInstance.processId) ?? null, + })), + ); + + // 4. Build translatable entries for all proposals + const entries: TranslatableEntry[] = []; + + for (const proposal of proposals) { + const proposalData = proposal.proposalData as Record; + const pid = proposal.profileId; + + if (!pid) { + continue; + } + + if (proposalData.title && typeof proposalData.title === 'string') { + entries.push({ + contentKey: `batch:${pid}:title`, + text: proposalData.title, + }); + } + + if (proposalData.category && typeof proposalData.category === 'string') { + entries.push({ + contentKey: `batch:${pid}:category`, + text: proposalData.category, + }); + } + + // Generate HTML from document content and extract plain-text preview + const documentContent = documentContentMap.get(proposal.id); + let htmlContent: Record | undefined; + + if (documentContent?.type === 'json') { + htmlContent = generateProposalHtml(documentContent.fragments); + } else if (documentContent?.type === 'html') { + htmlContent = { default: documentContent.content }; + } + + if (htmlContent) { + const firstHtml = Object.values(htmlContent).find(Boolean); + if (firstHtml) { + const plainText = getTextPreview({ + content: firstHtml, + maxLines: 3, + maxLength: 200, + }); + if (plainText) { + entries.push({ + contentKey: `batch:${pid}:preview`, + text: plainText, + }); + } + } + } + } + + if (entries.length === 0) { + return { translations: {}, sourceLocale: '', targetLocale }; + } + + // 5. Translate via DeepL with cache-through + const results = await runTranslateBatch(entries, targetLocale); + + // 6. Build response grouped by profileId + const translations: Record< + string, + { title?: string; category?: string; preview?: string } + > = {}; + let sourceLocale = ''; + + for (const result of results) { + // Key format: batch:: + const parts = result.contentKey.split(':'); + if (parts.length < 3 || parts[0] !== 'batch') { + continue; + } + const field = parts[parts.length - 1] as 'title' | 'category' | 'preview'; + const profileId = parts.slice(1, -1).join(':'); + + if (!translations[profileId]) { + translations[profileId] = {}; + } + translations[profileId][field] = result.translatedText; + + if (!sourceLocale && result.sourceLocale) { + sourceLocale = result.sourceLocale; + } + } + + return { translations, sourceLocale, targetLocale }; +} diff --git a/services/api/src/routers/translation/index.ts b/services/api/src/routers/translation/index.ts index 5ffa80bb7..d1c24657c 100644 --- a/services/api/src/routers/translation/index.ts +++ b/services/api/src/routers/translation/index.ts @@ -1,4 +1,8 @@ import { mergeRouters } from '../../trpcFactory'; import { translateProposalRouter } from './translateProposal'; +import { translateProposalsRouter } from './translateProposals'; -export const translationRouter = mergeRouters(translateProposalRouter); +export const translationRouter = mergeRouters( + translateProposalRouter, + translateProposalsRouter, +); diff --git a/services/api/src/routers/translation/translateProposal.test.ts b/services/api/src/routers/translation/translateProposal.test.ts index 6f6271c98..d5b5422af 100644 --- a/services/api/src/routers/translation/translateProposal.test.ts +++ b/services/api/src/routers/translation/translateProposal.test.ts @@ -341,4 +341,193 @@ describe.concurrent('translation.translateProposal', () => { expect.objectContaining({ tagHandling: 'html' }), ); }); + + it('should translate template field titles, descriptions, and dropdown options', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const proposalTemplate = { + type: 'object', + properties: { + category: { + type: 'string', + title: 'Project Category', + description: 'Select the category for your proposal', + 'x-format': 'select', + oneOf: [ + { const: 'infrastructure', title: 'Infrastructure' }, + { const: 'education', title: 'Education' }, + { const: 'health', title: 'Health Services' }, + ], + }, + summary: { + type: 'string', + title: 'Executive Summary', + description: 'Brief overview of your proposal', + 'x-format': 'textarea', + }, + }, + }; + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + proposalTemplate, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Template Fields Test' }, + }); + + const { collaborationDocId } = proposal.proposalData as { + collaborationDocId: string; + }; + mockCollab.setDocResponse(collaborationDocId, { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Body content' }], + }, + ], + }); + + const proposalId = proposal.id; + onTestFinished(async () => { + await db + .delete(contentTranslations) + .where( + like(contentTranslations.contentKey, `proposal:${proposalId}:%`), + ); + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const result = await caller.translation.translateProposal({ + profileId: proposal.profileId, + targetLocale: 'es', + }); + + // Verify template field titles are translated + expect(result.translated['field_title:category']).toBe( + '[ES] Project Category', + ); + expect(result.translated['field_title:summary']).toBe( + '[ES] Executive Summary', + ); + + // Verify template field descriptions are translated + expect(result.translated['field_desc:category']).toBe( + '[ES] Select the category for your proposal', + ); + expect(result.translated['field_desc:summary']).toBe( + '[ES] Brief overview of your proposal', + ); + + // Verify dropdown option labels are translated + expect(result.translated['option:category:infrastructure']).toBe( + '[ES] Infrastructure', + ); + expect(result.translated['option:category:education']).toBe( + '[ES] Education', + ); + expect(result.translated['option:category:health']).toBe( + '[ES] Health Services', + ); + + // Verify standard fields are still present + expect(result.translated['title']).toBe('[ES] Template Fields Test'); + expect(result.sourceLocale).toBe('EN'); + expect(result.targetLocale).toBe('es'); + }); + + it('should use cached template field translations without calling DeepL', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + const translationData = new TestTranslationDataManager(onTestFinished); + + const proposalTemplate = { + type: 'object', + properties: { + region: { + type: 'string', + title: 'Target Region', + 'x-format': 'text', + }, + }, + }; + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + proposalTemplate, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Cached Template Test' }, + }); + + const { collaborationDocId } = proposal.proposalData as { + collaborationDocId: string; + }; + mockCollab.setDocResponse(collaborationDocId, { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Some body' }], + }, + ], + }); + + // Pre-seed the field title translation in the cache + await translationData.seedTranslation({ + contentKey: `proposal:${proposal.id}:field_title:region`, + sourceText: 'Target Region', + translatedText: '[ES-CACHED] Target Region', + sourceLocale: 'EN', + targetLocale: 'ES', + }); + + onTestFinished(async () => { + await db + .delete(contentTranslations) + .where( + like(contentTranslations.contentKey, `proposal:${proposal.id}:%`), + ); + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const result = await caller.translation.translateProposal({ + profileId: proposal.profileId, + targetLocale: 'es', + }); + + // Field title should come from cache + expect(result.translated['field_title:region']).toBe( + '[ES-CACHED] Target Region', + ); + + // Title and body should go through DeepL mock + expect(result.translated['title']).toBe('[ES] Cached Template Test'); + }); }); diff --git a/services/api/src/routers/translation/translateProposals.test.ts b/services/api/src/routers/translation/translateProposals.test.ts new file mode 100644 index 000000000..be5c3df01 --- /dev/null +++ b/services/api/src/routers/translation/translateProposals.test.ts @@ -0,0 +1,269 @@ +import { mockCollab } from '@op/collab/testing'; +import { db } from '@op/db/client'; +import { contentTranslations } from '@op/db/schema'; +import { like } from 'drizzle-orm'; +import { describe, expect, it, vi } from 'vitest'; + +import { appRouter } from '..'; +import { TestDecisionsDataManager } from '../../test/helpers/TestDecisionsDataManager'; +import { TestTranslationDataManager } from '../../test/helpers/TestTranslationDataManager'; +import { + createIsolatedSession, + createTestContextWithSession, +} from '../../test/supabase-utils'; +import { createCallerFactory } from '../../trpcFactory'; + +// Set a fake API key so the endpoint doesn't throw before reaching the mock +process.env.DEEPL_API_KEY = 'test-fake-key'; + +// Mock DeepL's translateText — prefixes each text with [ES] so we can +// distinguish mock translations from seeded cache entries ([ES-CACHED]). +const mockTranslateText = vi.fn((texts: string[]) => + texts.map((t) => ({ + text: `[ES] ${t}`, + detectedSourceLang: 'en', + })), +); + +// Mock deepl-node so we never hit the real API +vi.mock('deepl-node', () => ({ + DeepLClient: class { + translateText = mockTranslateText; + }, +})); + +const createCaller = createCallerFactory(appRouter); + +async function createAuthenticatedCaller(email: string) { + const { session } = await createIsolatedSession(email); + return createCaller(await createTestContextWithSession(session)); +} + +describe.concurrent('translation.translateProposals', () => { + it('should translate title and preview for multiple proposals', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // Create two proposals with different content + const proposal1 = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Solar Panel Initiative' }, + }); + + const proposal2 = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Water Purification Project' }, + }); + + // Set up mock TipTap documents for both proposals + const { collaborationDocId: docId1 } = proposal1.proposalData as { + collaborationDocId: string; + }; + mockCollab.setDocResponse(docId1, { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Installing solar panels in rural areas' }, + ], + }, + ], + }); + + const { collaborationDocId: docId2 } = proposal2.proposalData as { + collaborationDocId: string; + }; + mockCollab.setDocResponse(docId2, { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Clean water for remote communities' }, + ], + }, + ], + }); + + // Clean up translations for both proposals + onTestFinished(async () => { + await db + .delete(contentTranslations) + .where( + like( + contentTranslations.contentKey, + `batch:${proposal1.profileId}:%`, + ), + ); + await db + .delete(contentTranslations) + .where( + like( + contentTranslations.contentKey, + `batch:${proposal2.profileId}:%`, + ), + ); + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const result = await caller.translation.translateProposals({ + profileIds: [proposal1.profileId, proposal2.profileId], + targetLocale: 'es', + }); + + expect(result.targetLocale).toBe('es'); + expect(result.sourceLocale).toBe('EN'); + + // Verify proposal 1 translations are grouped by profileId + const t1 = result.translations[proposal1.profileId]; + expect(t1).toBeDefined(); + expect(t1?.title).toBe('[ES] Solar Panel Initiative'); + expect(t1?.preview).toMatch(/^\[ES\] .*solar panels/i); + + // Verify proposal 2 translations are grouped by profileId + const t2 = result.translations[proposal2.profileId]; + expect(t2).toBeDefined(); + expect(t2?.title).toBe('[ES] Water Purification Project'); + expect(t2?.preview).toMatch(/^\[ES\] .*clean water/i); + }); + + it('should return cached batch translations without calling DeepL', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + const translationData = new TestTranslationDataManager(onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Cached Batch Proposal' }, + }); + + const { collaborationDocId } = proposal.proposalData as { + collaborationDocId: string; + }; + mockCollab.setDocResponse(collaborationDocId, { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Some preview content' }], + }, + ], + }); + + // Pre-seed the title translation in the batch cache + await translationData.seedTranslation({ + contentKey: `batch:${proposal.profileId}:title`, + sourceText: 'Cached Batch Proposal', + translatedText: '[ES-CACHED] Cached Batch Proposal', + sourceLocale: 'EN', + targetLocale: 'ES', + }); + + onTestFinished(async () => { + await db + .delete(contentTranslations) + .where( + like(contentTranslations.contentKey, `batch:${proposal.profileId}:%`), + ); + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const result = await caller.translation.translateProposals({ + profileIds: [proposal.profileId], + targetLocale: 'es', + }); + + const t = result.translations[proposal.profileId]; + expect(t).toBeDefined(); + + // Title should come from cache + expect(t?.title).toBe('[ES-CACHED] Cached Batch Proposal'); + + // Preview should go through DeepL mock + expect(t?.preview).toMatch(/^\[ES\] /); + }); + + it('should translate title but omit preview when document is empty', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + instanceCount: 1, + grantAccess: true, + }); + + const instance = setup.instances[0]; + if (!instance) { + throw new Error('No instance created'); + } + + // Create a proposal with a title but an empty TipTap document + const proposal = await testData.createProposal({ + callerEmail: setup.userEmail, + processInstanceId: instance.instance.id, + proposalData: { title: 'Minimal Proposal' }, + }); + + // Set up an empty TipTap document (no text content → no preview) + const { collaborationDocId } = proposal.proposalData as { + collaborationDocId: string; + }; + mockCollab.setDocResponse(collaborationDocId, { + type: 'doc', + content: [], + }); + + onTestFinished(async () => { + await db + .delete(contentTranslations) + .where( + like(contentTranslations.contentKey, `batch:${proposal.profileId}:%`), + ); + }); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const result = await caller.translation.translateProposals({ + profileIds: [proposal.profileId], + targetLocale: 'es', + }); + + // Should still translate the title even with no preview + const t = result.translations[proposal.profileId]; + expect(t).toBeDefined(); + expect(t?.title).toBe('[ES] Minimal Proposal'); + expect(t?.preview).toBeUndefined(); + }); +}); diff --git a/services/api/src/routers/translation/translateProposals.ts b/services/api/src/routers/translation/translateProposals.ts new file mode 100644 index 000000000..17e9459ff --- /dev/null +++ b/services/api/src/routers/translation/translateProposals.ts @@ -0,0 +1,35 @@ +import { SUPPORTED_LOCALES, translateProposals } from '@op/common'; +import { z } from 'zod'; + +import { commonAuthedProcedure, router } from '../../trpcFactory'; + +export const translateProposalsRouter = router({ + translateProposals: commonAuthedProcedure() + .input( + z.object({ + profileIds: z.array(z.uuid()).min(1).max(100), + targetLocale: z.enum(SUPPORTED_LOCALES), + }), + ) + .output( + z.object({ + translations: z.record( + z.string(), + z.object({ + title: z.string().optional(), + category: z.string().optional(), + preview: z.string().optional(), + }), + ), + sourceLocale: z.string(), + targetLocale: z.enum(SUPPORTED_LOCALES), + }), + ) + .mutation(async ({ input, ctx }) => { + return translateProposals({ + profileIds: input.profileIds, + targetLocale: input.targetLocale, + user: ctx.user, + }); + }), +}); From 416adbdabc2221467beea26535f63bc695c0483a Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 21:55:46 +0100 Subject: [PATCH 10/14] Show Invites in the Participants List (#698) Shows the pending invites in the invite modal for the decision invite modal. A follow up PR updates the role display to a dropdown selector. Screenshot 2026-03-02 at 13 40 25 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213319501728175 --- .gitignore | 1 + .../decisions/ProfileInviteModal.tsx | 384 +++++++++++++----- .../decisions/ProfileUsersAccess.tsx | 9 +- .../decisions/ProfileUsersAccessTable.tsx | 169 +++++++- apps/app/src/lib/i18n/dictionaries/bn.json | 1 + apps/app/src/lib/i18n/dictionaries/en.json | 1 + apps/app/src/lib/i18n/dictionaries/es.json | 1 + apps/app/src/lib/i18n/dictionaries/fr.json | 1 + apps/app/src/lib/i18n/dictionaries/pt.json | 1 + packages/common/src/services/index.ts | 1 + packages/common/src/services/profile/index.ts | 1 + .../services/profile/updateProfileInvite.ts | 84 ++++ services/api/src/encoders/profiles.ts | 8 +- services/api/src/routers/profile/index.ts | 2 + .../profile/listProfileInvites.test.ts | 162 ++++++++ .../src/routers/profile/listProfileInvites.ts | 12 +- .../routers/profile/updateProfileInvite.ts | 31 ++ 17 files changed, 752 insertions(+), 117 deletions(-) create mode 100644 packages/common/src/services/profile/updateProfileInvite.ts create mode 100644 services/api/src/routers/profile/listProfileInvites.test.ts create mode 100644 services/api/src/routers/profile/updateProfileInvite.ts diff --git a/.gitignore b/.gitignore index bea07953d..5a7e4f893 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,4 @@ tests/e2e/playwright-report/ tests/e2e/test-results/ tests/e2e/playwright/.auth/ .playwright-mcp +ralph.sh diff --git a/apps/app/src/components/decisions/ProfileInviteModal.tsx b/apps/app/src/components/decisions/ProfileInviteModal.tsx index 8d5f4bb67..938ca9e8b 100644 --- a/apps/app/src/components/decisions/ProfileInviteModal.tsx +++ b/apps/app/src/components/decisions/ProfileInviteModal.tsx @@ -16,9 +16,11 @@ import { toast } from '@op/ui/Toast'; import Image from 'next/image'; import { Key, + type ReactNode, Suspense, useEffect, useMemo, + useOptimistic, useRef, useState, useTransition, @@ -29,6 +31,8 @@ import { LuLeaf, LuX } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; +import { Bullet } from '../Bullet'; +import ErrorBoundary from '../ErrorBoundary'; import { RoleSelector, RoleSelectorSkeleton } from './RoleSelector'; import { isValidEmail, parseEmailPaste } from './emailUtils'; @@ -53,6 +57,51 @@ export const ProfileInviteModal = ({ onOpenChange: (isOpen: boolean) => void; }) => { const t = useTranslations(); + + const handleClose = () => { + onOpenChange(false); + }; + + return ( + + + {t('Invite participants to your decision-making process')} + + + + + +
+ +
+ + } + > + +
+
+
+ ); +}; + +function ProfileInviteModalContent({ + profileId, + onOpenChange, +}: { + profileId: string; + onOpenChange: (isOpen: boolean) => void; +}) { + const t = useTranslations(); const utils = trpc.useUtils(); const [selectedItemsByRole, setSelectedItemsByRole] = useState({}); @@ -60,7 +109,8 @@ export const ProfileInviteModal = ({ const [selectedRoleName, setSelectedRoleName] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [debouncedQuery] = useDebounce(searchQuery, 200); - const [isSubmitting, startTransition] = useTransition(); + const [isSubmitting, startSendTransition] = useTransition(); + const [, startOptimisticTransition] = useTransition(); const searchContainerRef = useRef(null); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, @@ -68,6 +118,23 @@ export const ProfileInviteModal = ({ width: 0, }); + // Fetch existing pending invites and members + const [serverInvites] = trpc.profile.listProfileInvites.useSuspenseQuery({ + profileId, + }); + const [usersData] = trpc.profile.listUsers.useSuspenseQuery({ profileId }); + + const [optimisticInvites, dispatchRemoveInvite] = useOptimistic( + serverInvites, + (state, inviteId: string) => state.filter((i) => i.id !== inviteId), + ); + + const [optimisticUsers, dispatchRemoveUser] = useOptimistic( + usersData.items, + (state, profileUserId: string) => + state.filter((u) => u.id !== profileUserId), + ); + // Get items for current role const currentRoleItems = selectedItemsByRole[selectedRoleId] ?? []; @@ -77,6 +144,21 @@ export const ProfileInviteModal = ({ [selectedItemsByRole], ); + // Filter server invites by current role + const currentRoleInvites = useMemo( + () => optimisticInvites.filter((i) => i.accessRoleId === selectedRoleId), + [optimisticInvites, selectedRoleId], + ); + + // Filter members by current role + const currentRoleMembers = useMemo( + () => + optimisticUsers.filter((u) => + u.roles.some((r) => r.id === selectedRoleId), + ), + [optimisticUsers, selectedRoleId], + ); + // Update dropdown position when search query changes useEffect(() => { if (debouncedQuery.length >= 2 && searchContainerRef.current) { @@ -112,45 +194,66 @@ export const ProfileInviteModal = ({ .sort((a, b) => b.rank - a.rank); }, [searchResults]); - // Filter out already selected items (across all roles) + // Filter out already selected, already invited, and existing members const filteredResults = useMemo(() => { const selectedIds = new Set(allSelectedItems.map((item) => item.profileId)); const selectedEmails = new Set( allSelectedItems.map((item) => item.email.toLowerCase()), ); + const existingUserEmails = new Set( + optimisticUsers.map((u) => u.email.toLowerCase()), + ); + const invitedEmails = new Set( + optimisticInvites.map((i) => i.email.toLowerCase()), + ); return flattenedResults.filter( (result) => !selectedIds.has(result.id) && (!result.user?.email || - !selectedEmails.has(result.user.email.toLowerCase())), + (!selectedEmails.has(result.user.email.toLowerCase()) && + !existingUserEmails.has(result.user.email.toLowerCase()) && + !invitedEmails.has(result.user.email.toLowerCase()))), ); - }, [flattenedResults, allSelectedItems]); + }, [flattenedResults, allSelectedItems, optimisticUsers, optimisticInvites]); // Check if query is a valid email that hasn't been selected yet (across all roles) const canAddEmail = useMemo(() => { if (!isValidEmail(debouncedQuery)) { return false; } - const selectedEmails = new Set( - allSelectedItems.map((item) => item.email.toLowerCase()), - ); - return !selectedEmails.has(debouncedQuery.toLowerCase()); - }, [debouncedQuery, allSelectedItems]); - - // Invite mutation + const lowerQuery = debouncedQuery.toLowerCase(); + const takenEmails = new Set([ + ...allSelectedItems.map((item) => item.email.toLowerCase()), + ...optimisticUsers.map((u) => u.email.toLowerCase()), + ...optimisticInvites.map((i) => i.email.toLowerCase()), + ]); + return !takenEmails.has(lowerQuery); + }, [debouncedQuery, allSelectedItems, optimisticUsers, optimisticInvites]); + + // Mutations const inviteMutation = trpc.profile.invite.useMutation(); + const deleteInviteMutation = trpc.profile.deleteProfileInvite.useMutation(); + const removeUserMutation = trpc.profile.removeUser.useMutation(); - // Calculate total people count across all roles + // Calculate total people count across all roles (staged only) const totalPeople = allSelectedItems.length; - // Calculate counts by role for the tab badges + // Calculate counts by role for the tab badges (staged + server invites + members) const countsByRole = useMemo(() => { const counts: Record = {}; for (const [roleId, items] of Object.entries(selectedItemsByRole)) { counts[roleId] = items.length; } + for (const invite of optimisticInvites) { + counts[invite.accessRoleId] = (counts[invite.accessRoleId] ?? 0) + 1; + } + for (const user of optimisticUsers) { + for (const role of user.roles) { + counts[role.id] = (counts[role.id] ?? 0) + 1; + } + } return counts; - }, [selectedItemsByRole]); + }, [selectedItemsByRole, optimisticInvites, optimisticUsers]); const handleSelectItem = (result: (typeof flattenedResults)[0]) => { if (!result.user?.email || !selectedRoleId) { @@ -199,8 +302,32 @@ export const ProfileInviteModal = ({ })); }; + const handleDeleteInvite = (inviteId: string) => { + startOptimisticTransition(async () => { + dispatchRemoveInvite(inviteId); + try { + await deleteInviteMutation.mutateAsync({ inviteId }); + } catch { + toast.error({ message: t('Failed to cancel invite') }); + } + await utils.profile.listProfileInvites.invalidate({ profileId }); + }); + }; + + const handleRemoveUser = (profileUserId: string) => { + startOptimisticTransition(async () => { + dispatchRemoveUser(profileUserId); + try { + await removeUserMutation.mutateAsync({ profileUserId }); + } catch { + toast.error({ message: t('Failed to remove user') }); + } + await utils.profile.listUsers.invalidate({ profileId }); + }); + }; + const handleSend = () => { - startTransition(async () => { + startSendTransition(async () => { try { // Collect all invitations across all roles into a single array const invitations = Object.entries(selectedItemsByRole) @@ -223,8 +350,9 @@ export const ProfileInviteModal = ({ setSearchQuery(''); onOpenChange(false); - // Invalidate the profile users list + // Invalidate both lists utils.profile.listUsers.invalidate({ profileId }); + utils.profile.listProfileInvites.invalidate({ profileId }); } catch (error) { const message = error instanceof Error ? error.message : t('Failed to send invite'); @@ -239,9 +367,11 @@ export const ProfileInviteModal = ({ return; } - const existingEmails = new Set( - allSelectedItems.map((item) => item.email.toLowerCase()), - ); + const existingEmails = new Set([ + ...allSelectedItems.map((item) => item.email.toLowerCase()), + ...optimisticUsers.map((u) => u.email.toLowerCase()), + ...optimisticInvites.map((i) => i.email.toLowerCase()), + ]); const emails = parseEmailPaste(pastedText, existingEmails); if (!emails) { return; @@ -264,42 +394,30 @@ export const ProfileInviteModal = ({ setSearchQuery(''); }; - const handleClose = () => { - setSelectedItemsByRole({}); - setSearchQuery(''); - onOpenChange(false); - }; - const handleTabChange = (key: Key) => { setSelectedRoleId(String(key)); }; - return ( - - - {t('Invite participants to your decision-making process')} - + const hasNoItems = + currentRoleItems.length === 0 && + currentRoleInvites.length === 0 && + currentRoleMembers.length === 0; + return ( + <> {/* Role Tabs */} - }> - { - setSelectedRoleId(roleId); - setSelectedRoleName(roleName); - }} - onRoleNameChange={setSelectedRoleName} - /> - + { + setSelectedRoleId(roleId); + setSelectedRoleName(roleName); + }} + onRoleNameChange={setSelectedRoleName} + /> {/* Search Input */}
@@ -399,52 +517,99 @@ export const ProfileInviteModal = ({ document.body, )} - {/* Selected Items for Current Role */} - {currentRoleItems.length > 0 ? ( -
+ {/* People list for current role */} +
+ {!hasNoItems && ( + + {t('People with access')} + + )} + +
+ {/* Staged items (not yet sent) */} {currentRoleItems.map((item) => ( -
- - {item.avatarUrl ? ( - {item.name} - ) : null} - + name={item.name} + avatarUrl={item.avatarUrl} + subtitle={ + item.name !== item.email ? ( +
+ {item.email} +
+ ) : undefined + } + onRemove={() => handleRemoveItem(item.id)} + removeLabel={t('Remove {name}', { name: item.name })} + /> + ))} + + {/* Pending invites from server */} + {currentRoleInvites.map((invite) => { + const displayName = invite.inviteeProfile?.name ?? invite.email; + const avatarUrl = invite.inviteeProfile?.avatarImage?.name + ? getPublicUrl(invite.inviteeProfile.avatarImage.name) + : undefined; + + return ( + + {invite.inviteeProfile?.name && ( + <> + {invite.email} {' '} + + )} + + {t('Invited')} + +
} - title={item.name} - > - - {item.email} - - - handleRemoveItem(item.id)} - aria-label={t('Remove {name}', { name: item.name })} - > - - -
+ onRemove={() => handleDeleteInvite(invite.id)} + removeLabel={t('Remove {name}', { name: displayName })} + /> + ); + })} + + {/* Existing members */} + {currentRoleMembers.map((user) => ( + + {user.email} +
+ ) : undefined + } + onRemove={ + !user.isOwner ? () => handleRemoveUser(user.id) : undefined + } + removeLabel={t('Remove {name}', { + name: user.name ?? user.email, + })} + /> ))} + + {/* Empty state */} + {hasNoItems && selectedRoleName ? ( + }> + {t('No {roleName}s have been added', { + roleName: selectedRoleName, + })} + + ) : null}
- ) : selectedRoleName ? ( - }> - {t('No {roleName}s have been added', { - roleName: selectedRoleName, - })} - - ) : null} +
@@ -464,6 +629,43 @@ export const ProfileInviteModal = ({ {isSubmitting ? t('Sending...') : t('Send')} -
+ ); -}; +} + +function PersonRow({ + name, + avatarUrl, + subtitle, + onRemove, + removeLabel, +}: { + name: string; + avatarUrl?: string; + subtitle?: ReactNode; + onRemove?: () => void; + removeLabel: string; +}) { + return ( +
+ + {avatarUrl ? ( + {name} + ) : null} + + } + title={name} + > + {subtitle} + + {onRemove && ( + + + + )} +
+ ); +} diff --git a/apps/app/src/components/decisions/ProfileUsersAccess.tsx b/apps/app/src/components/decisions/ProfileUsersAccess.tsx index 3dd85f6c7..d79c6ea92 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccess.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccess.tsx @@ -68,6 +68,12 @@ export const ProfileUsersAccess = ({ profileId }: { profileId: string }) => { const { data: rolesData, isPending: rolesPending } = trpc.profile.listRoles.useQuery({ profileId }); + // Fetch pending invites to show alongside accepted members + const { data: invites } = trpc.profile.listProfileInvites.useQuery( + { profileId }, + { retry: false }, + ); + const { items: profileUsers = [], next } = data ?? {}; const roles = rolesData?.items ?? []; @@ -82,7 +88,7 @@ export const ProfileUsersAccess = ({ profileId }: { profileId: string }) => {

- {t('Members')} + {t('Participants')}

)} + {invites.map((invite) => { + const displayName = invite.inviteeProfile?.name ?? invite.email; + + return ( + + +
+ +
+ + {displayName} + + + {t('Invited')} + +
+
+
+ + + {invite.email} + + + + + +
+ ); + })} {profileUsers.map((profileUser) => { const displayName = profileUser.profile?.name || diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index aeaf1e2ea..21602570c 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -490,6 +490,7 @@ "Role updated successfully": "ভূমিকা সফলভাবে আপডেট করা হয়েছে", "Failed to update role": "ভূমিকা আপডেট করতে ব্যর্থ", "Members list": "সদস্যদের তালিকা", + "Participants list": "অংশগ্রহণকারীদের তালিকা", "Overview": "সারসংক্ষেপ", "Proposal Template": "প্রস্তাব টেমপ্লেট", "Review Rubric": "পর্যালোচনা রুব্রিক", diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index dfaadf48d..c8dcc3c03 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -483,6 +483,7 @@ "Role updated successfully": "Role updated successfully", "Failed to update role": "Failed to update role", "Members list": "Members list", + "Participants list": "Participants list", "Overview": "Overview", "Proposal Template": "Proposal Template", "Review Rubric": "Review Rubric", diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index d87539f70..7a21acab2 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -482,6 +482,7 @@ "Role updated successfully": "Rol actualizado exitosamente", "Failed to update role": "Error al actualizar el rol", "Members list": "Lista de miembros", + "Participants list": "Lista de participantes", "Overview": "Resumen", "Proposal Template": "Plantilla de propuesta", "Review Rubric": "Rúbrica de revisión", diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 538bef132..a865df594 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -483,6 +483,7 @@ "Role updated successfully": "Rôle mis à jour avec succès", "Failed to update role": "Échec de la mise à jour du rôle", "Members list": "Liste des membres", + "Participants list": "Liste des participants", "Overview": "Aperçu", "Proposal Template": "Modèle de proposition", "Review Rubric": "Grille d'évaluation", diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 7fc1f8c44..d6d7e57e5 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -483,6 +483,7 @@ "Role updated successfully": "Função atualizada com sucesso", "Failed to update role": "Falha ao atualizar a função", "Members list": "Lista de membros", + "Participants list": "Lista de participantes", "Overview": "Visão geral", "Proposal Template": "Modelo de proposta", "Review Rubric": "Rubrica de avaliação", diff --git a/packages/common/src/services/index.ts b/packages/common/src/services/index.ts index e93c0f7cf..06cf7da7c 100644 --- a/packages/common/src/services/index.ts +++ b/packages/common/src/services/index.ts @@ -14,6 +14,7 @@ export { acceptProfileInvite, declineProfileInvite, deleteProfileInvite, + updateProfileInvite, updateUserProfile, getProfile, searchProfiles, diff --git a/packages/common/src/services/profile/index.ts b/packages/common/src/services/profile/index.ts index a547992e5..4a2fa335b 100644 --- a/packages/common/src/services/profile/index.ts +++ b/packages/common/src/services/profile/index.ts @@ -10,6 +10,7 @@ export * from './inviteUsersToProfile'; export * from './listProfileUsers'; export * from './listProfileUserInvites'; export * from './listUserInvites'; +export * from './updateProfileInvite'; export * from './updateProfileUserRole'; export * from './removeProfileUser'; export * from './getProfileUserWithRelations'; diff --git a/packages/common/src/services/profile/updateProfileInvite.ts b/packages/common/src/services/profile/updateProfileInvite.ts new file mode 100644 index 000000000..33a1edfa7 --- /dev/null +++ b/packages/common/src/services/profile/updateProfileInvite.ts @@ -0,0 +1,84 @@ +import { and, db, eq, isNull } from '@op/db/client'; +import { profileInvites } from '@op/db/schema'; +import type { User } from '@op/supabase/lib'; +import { assertAccess, permission } from 'access-zones'; + +import { + CommonError, + NotFoundError, + UnauthorizedError, +} from '../../utils/error'; +import { getProfileAccessUser } from '../access'; + +/** + * Update a pending profile invite's role. + * Only admins of the profile can update invites. + */ +export const updateProfileInvite = async ({ + inviteId, + accessRoleId, + user, +}: { + inviteId: string; + accessRoleId: string; + user: User; +}) => { + // Fetch invite and validate role in parallel (independent queries) + const [invite, role] = await Promise.all([ + db.query.profileInvites.findFirst({ + where: { + id: inviteId, + acceptedOn: { isNull: true }, + }, + with: { + profile: true, + inviteeProfile: { + with: { + avatarImage: true, + }, + }, + }, + }), + db.query.accessRoles.findFirst({ + where: { id: accessRoleId }, + }), + ]); + + if (!invite) { + throw new NotFoundError('Invite not found'); + } + + if (!role) { + throw new CommonError('Invalid role specified'); + } + + // Check if user has ADMIN access on the profile + const profileAccessUser = await getProfileAccessUser({ + user, + profileId: invite.profileId, + }); + + if (!profileAccessUser) { + throw new UnauthorizedError('You do not have access to this profile'); + } + + assertAccess({ profile: permission.ADMIN }, profileAccessUser.roles ?? []); + + // Update the invite + const [updated] = await db + .update(profileInvites) + .set({ accessRoleId }) + .where( + and(eq(profileInvites.id, inviteId), isNull(profileInvites.acceptedOn)), + ) + .returning(); + + if (!updated) { + throw new CommonError('Failed to update invite'); + } + + return { + ...updated, + inviteeProfile: invite.inviteeProfile ?? null, + }; +}; diff --git a/services/api/src/encoders/profiles.ts b/services/api/src/encoders/profiles.ts index a73e82c46..70b91e2cd 100644 --- a/services/api/src/encoders/profiles.ts +++ b/services/api/src/encoders/profiles.ts @@ -99,17 +99,17 @@ export const profileUserEncoder = createSelectSchema(profileUsers).extend({ roles: z.array(accessRoleMinimalEncoder), }); -// Profile invite encoder for pending invitations -// accessRoleId is NOT NULL in schema, so role is always present +// Profile invite encoder for pending invitations returned by listProfileInvites export const profileInviteEncoder = createSelectSchema(profileInvites) .pick({ id: true, email: true, - profileId: true, + accessRoleId: true, createdAt: true, }) .extend({ - role: accessRoleMinimalEncoder, + inviteeProfile: profileMinimalEncoder.nullable(), }); export type ProfileUser = z.infer; +export type ProfileInvite = z.infer; diff --git a/services/api/src/routers/profile/index.ts b/services/api/src/routers/profile/index.ts index 7d5b80e6f..f1614387e 100644 --- a/services/api/src/routers/profile/index.ts +++ b/services/api/src/routers/profile/index.ts @@ -19,6 +19,7 @@ import { } from './requests'; import { searchProfilesRouter } from './searchProfiles'; import { updateDecisionRolesRouter } from './updateDecisionRoles'; +import { updateProfileInviteRouter } from './updateProfileInvite'; import { updateRolePermissionRouter } from './updateRolePermission'; import { usersRouter } from './users'; @@ -28,6 +29,7 @@ const profileRouter = mergeRouters( getDecisionRoleRouter, updateDecisionRolesRouter, deleteProfileInviteRouter, + updateProfileInviteRouter, getProfileRouter, searchProfilesRouter, profileRelationshipRouter, diff --git a/services/api/src/routers/profile/listProfileInvites.test.ts b/services/api/src/routers/profile/listProfileInvites.test.ts new file mode 100644 index 000000000..aef6bcd7f --- /dev/null +++ b/services/api/src/routers/profile/listProfileInvites.test.ts @@ -0,0 +1,162 @@ +import { db } from '@op/db/client'; +import { EntityType, profileInvites } from '@op/db/schema'; +import { ROLES } from '@op/db/seedData/accessControl'; +import { describe, expect, it } from 'vitest'; + +import { TestProfileUserDataManager } from '../../test/helpers/TestProfileUserDataManager'; +import { + createIsolatedSession, + createTestContextWithSession, +} from '../../test/supabase-utils'; +import { createCallerFactory } from '../../trpcFactory'; +import profileRouter from './index'; + +describe.concurrent('profile.listProfileInvites', () => { + const createCaller = createCallerFactory(profileRouter); + + it('should return pending invites with accessRoleId', async ({ + task, + onTestFinished, + }) => { + const testData = new TestProfileUserDataManager(task.id, onTestFinished); + const { profile, adminUser } = await testData.createProfile({ + users: { admin: 1 }, + }); + + // Create a pending invite + const inviteeEmail = `invitee-${task.id}@oneproject.org`; + testData.trackProfileInvite(inviteeEmail, profile.id); + + await db.insert(profileInvites).values({ + email: inviteeEmail, + profileId: profile.id, + profileEntityType: EntityType.ORG, + accessRoleId: ROLES.MEMBER.id, + invitedBy: adminUser.userProfileId, + }); + + const { session } = await createIsolatedSession(adminUser.email); + const caller = createCaller(await createTestContextWithSession(session)); + + const result = await caller.listProfileInvites({ + profileId: profile.id, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + email: inviteeEmail, + accessRoleId: ROLES.MEMBER.id, + }); + }); + + it('should return correct accessRoleId for different roles', async ({ + task, + onTestFinished, + }) => { + const testData = new TestProfileUserDataManager(task.id, onTestFinished); + const { profile, adminUser } = await testData.createProfile({ + users: { admin: 1 }, + }); + + // Create invites with different roles + const memberEmail = `member-invite-${task.id}@oneproject.org`; + const adminEmail = `admin-invite-${task.id}@oneproject.org`; + testData.trackProfileInvite(memberEmail, profile.id); + testData.trackProfileInvite(adminEmail, profile.id); + + await db.insert(profileInvites).values([ + { + email: memberEmail, + profileId: profile.id, + profileEntityType: EntityType.ORG, + accessRoleId: ROLES.MEMBER.id, + invitedBy: adminUser.userProfileId, + }, + { + email: adminEmail, + profileId: profile.id, + profileEntityType: EntityType.ORG, + accessRoleId: ROLES.ADMIN.id, + invitedBy: adminUser.userProfileId, + }, + ]); + + const { session } = await createIsolatedSession(adminUser.email); + const caller = createCaller(await createTestContextWithSession(session)); + + const result = await caller.listProfileInvites({ + profileId: profile.id, + }); + + expect(result).toHaveLength(2); + + const memberInvite = result.find((r) => r.email === memberEmail); + const adminInvite = result.find((r) => r.email === adminEmail); + + expect(memberInvite?.accessRoleId).toBe(ROLES.MEMBER.id); + expect(adminInvite?.accessRoleId).toBe(ROLES.ADMIN.id); + }); + + it('should only return pending invites (not accepted ones)', async ({ + task, + onTestFinished, + }) => { + const testData = new TestProfileUserDataManager(task.id, onTestFinished); + const { profile, adminUser } = await testData.createProfile({ + users: { admin: 1 }, + }); + + const pendingEmail = `pending-${task.id}@oneproject.org`; + const acceptedEmail = `accepted-${task.id}@oneproject.org`; + testData.trackProfileInvite(pendingEmail, profile.id); + testData.trackProfileInvite(acceptedEmail, profile.id); + + await db.insert(profileInvites).values([ + { + email: pendingEmail, + profileId: profile.id, + profileEntityType: EntityType.ORG, + accessRoleId: ROLES.MEMBER.id, + invitedBy: adminUser.userProfileId, + }, + { + email: acceptedEmail, + profileId: profile.id, + profileEntityType: EntityType.ORG, + accessRoleId: ROLES.MEMBER.id, + invitedBy: adminUser.userProfileId, + acceptedOn: new Date().toISOString(), + }, + ]); + + const { session } = await createIsolatedSession(adminUser.email); + const caller = createCaller(await createTestContextWithSession(session)); + + const result = await caller.listProfileInvites({ + profileId: profile.id, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.email).toBe(pendingEmail); + expect(result[0]?.accessRoleId).toBe(ROLES.MEMBER.id); + }); + + it('should return empty array when no pending invites exist', async ({ + task, + onTestFinished, + }) => { + const testData = new TestProfileUserDataManager(task.id, onTestFinished); + const { profile, adminUser } = await testData.createProfile({ + users: { admin: 1 }, + }); + + const { session } = await createIsolatedSession(adminUser.email); + const caller = createCaller(await createTestContextWithSession(session)); + + const result = await caller.listProfileInvites({ + profileId: profile.id, + }); + + expect(result).toHaveLength(0); + }); +}); diff --git a/services/api/src/routers/profile/listProfileInvites.ts b/services/api/src/routers/profile/listProfileInvites.ts index e34948c36..4f7de0fb0 100644 --- a/services/api/src/routers/profile/listProfileInvites.ts +++ b/services/api/src/routers/profile/listProfileInvites.ts @@ -1,7 +1,7 @@ import { listProfileUserInvites } from '@op/common'; import { z } from 'zod'; -import { profileMinimalEncoder } from '../../encoders/profiles'; +import { profileInviteEncoder } from '../../encoders/profiles'; import { commonAuthedProcedure, router } from '../../trpcFactory'; const inputSchema = z.object({ @@ -9,14 +9,7 @@ const inputSchema = z.object({ query: z.string().min(2).optional(), }); -const outputSchema = z.array( - z.object({ - id: z.string(), - email: z.string(), - createdAt: z.string().nullable(), - inviteeProfile: profileMinimalEncoder.nullable(), - }), -); +const outputSchema = z.array(profileInviteEncoder); export const listProfileInvitesRouter = router({ listProfileInvites: commonAuthedProcedure() @@ -32,6 +25,7 @@ export const listProfileInvitesRouter = router({ return invites.map((invite) => ({ id: invite.id, email: invite.email, + accessRoleId: invite.accessRoleId, createdAt: invite.createdAt, inviteeProfile: invite.inviteeProfile ?? null, })); diff --git a/services/api/src/routers/profile/updateProfileInvite.ts b/services/api/src/routers/profile/updateProfileInvite.ts new file mode 100644 index 000000000..599f7bbd9 --- /dev/null +++ b/services/api/src/routers/profile/updateProfileInvite.ts @@ -0,0 +1,31 @@ +import { updateProfileInvite } from '@op/common'; +import { z } from 'zod'; + +import { profileInviteEncoder } from '../../encoders/profiles'; +import { commonAuthedProcedure, router } from '../../trpcFactory'; + +export const updateProfileInviteRouter = router({ + updateProfileInvite: commonAuthedProcedure() + .input( + z.object({ + inviteId: z.string().uuid(), + accessRoleId: z.string().uuid(), + }), + ) + .output(profileInviteEncoder) + .mutation(async ({ ctx, input }) => { + const invite = await updateProfileInvite({ + inviteId: input.inviteId, + accessRoleId: input.accessRoleId, + user: ctx.user, + }); + + return { + id: invite.id, + email: invite.email, + accessRoleId: invite.accessRoleId, + createdAt: invite.createdAt, + inviteeProfile: invite.inviteeProfile, + }; + }), +}); From 09aabb46e3c491f37c0f7f62111298e1134af27a Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Tue, 3 Mar 2026 21:56:19 +0100 Subject: [PATCH 11/14] Budget component sizing based on text (#705) Fixes the sizing on the "Add Budget" component so that it remains the same size when you focus. Also converts the currency display to better match that currency's format. We also now size the input and the Add Budget to whichever of the two are larger (placeholder text in the input vs the "Add Budget" text in the button) since this varies by locale. ### Budget entry Screenshot 2026-03-03 at 17 47 10 Screenshot 2026-03-03 at 17 47 17 Screenshot 2026-03-03 at 17 47 35 Screenshot 2026-03-03 at 17 47 41 ### Currency displays Screenshot 2026-03-03 at 17 20 48 Screenshot 2026-03-03 at 17 20 54 Screenshot 2026-03-03 at 17 20 20 (Swedish Krona) --- .../CollaborativeBudgetField.tsx | 142 +++++++++++------- 1 file changed, 89 insertions(+), 53 deletions(-) diff --git a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx index f38c9e4c2..2443a6b56 100644 --- a/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx +++ b/apps/app/src/components/collaboration/CollaborativeBudgetField.tsx @@ -4,23 +4,25 @@ import { useCollaborativeFragment } from '@/hooks/useCollaborativeFragment'; import type { BudgetData } from '@op/common/client'; import { Button } from '@op/ui/Button'; import { NumberField } from '@op/ui/NumberField'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslations } from '@/lib/i18n'; import { useCollaborativeDoc } from './CollaborativeDocContext'; const DEFAULT_CURRENCY = 'USD'; -const DEFAULT_CURRENCY_SYMBOL = '$'; -const CURRENCY_SYMBOLS: Record = { - USD: DEFAULT_CURRENCY_SYMBOL, -}; - -/** Formats a number as a locale-aware currency string (e.g. 5000 → "$5,000") */ -function formatBudgetDisplay(amount: number, currencySymbol: string): string { - return `${currencySymbol}${amount.toLocaleString()}`; -} +const getCurrencySymbol = (currency: string) => + (0) + .toLocaleString(undefined, { + style: 'currency', + currency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + .replace(/\d/g, '') + .trim(); interface CollaborativeBudgetFieldProps { maxAmount?: number; @@ -33,8 +35,9 @@ interface CollaborativeBudgetFieldProps { * Stores `MoneyAmount` (`{ amount, currency }`) as a JSON string in the shared doc * for future multi-currency support. * - * Displays as a pill when a value exists, switching to an inline - * NumberField on click for editing. + * Displays as a pill when a value exists or empty, switching to an inline + * NumberField on click for editing. The pill width matches the input width + * to prevent layout shifts. */ export function CollaborativeBudgetField({ maxAmount, @@ -69,11 +72,40 @@ export function CollaborativeBudgetField({ const [isEditing, setIsEditing] = useState(false); const budgetAmount = budget?.amount ?? null; - const currencySymbol = - CURRENCY_SYMBOLS[budget?.currency ?? DEFAULT_CURRENCY] ?? - DEFAULT_CURRENCY_SYMBOL; + const currency = budget?.currency ?? DEFAULT_CURRENCY; + const currencySymbol = useMemo(() => getCurrencySymbol(currency), [currency]); + + const placeholderText = maxAmount + ? t('Max {amount}', { amount: maxAmount.toLocaleString() }) + : t('Enter amount'); + + // Size the input to its placeholder text instead of the default size=20 + useLayoutEffect(() => { + if (budgetInputRef.current) { + budgetInputRef.current.size = placeholderText.length; + } + }, [placeholderText]); + + // Use the larger of the input and button natural widths so both match + const buttonRef = useRef(null); + const [sharedWidth, setSharedWidth] = useState(0); + + useEffect(() => { + if (isEditing) { + return; + } + const frame = requestAnimationFrame(() => { + const group = budgetInputRef.current?.closest('[role="group"]'); + const inputW = group instanceof HTMLElement ? group.offsetWidth : 0; + const buttonW = buttonRef.current?.scrollWidth ?? 0; + const width = Math.max(inputW, buttonW); + if (width > 0) { + setSharedWidth(width); + } + }); + return () => cancelAnimationFrame(frame); + }, [isEditing]); - // Auto-focus when switching to edit mode useEffect(() => { if (isEditing && budgetInputRef.current) { budgetInputRef.current.focus(); @@ -85,14 +117,14 @@ export function CollaborativeBudgetField({ setBudget(null); } else { setBudget({ - currency: budget?.currency ?? DEFAULT_CURRENCY, + currency, amount: value, }); } }; useEffect(() => { - const emitted: BudgetData | null = budget; + const emitted = budgetText ? (JSON.parse(budgetText) as BudgetData) : null; const key = emitted ? `${emitted.amount}:${emitted.currency}` : null; if (lastEmittedRef.current === key) { @@ -101,7 +133,7 @@ export function CollaborativeBudgetField({ lastEmittedRef.current = key ?? undefined; onChangeRef.current?.(emitted); - }, [budget]); + }, [budgetText]); const handleStartEditing = () => { setIsEditing(true); @@ -111,40 +143,44 @@ export function CollaborativeBudgetField({ setIsEditing(false); }; - // No value and not editing → "Add budget" pill - if (budgetAmount === null && !isEditing) { - return ( - - ); - } - - // Has a value and not editing → display as pill - if (budgetAmount !== null && !isEditing) { - return ( - - ); - } - - // Editing mode → inline NumberField return ( - + <> +
0 ? { minWidth: sharedWidth } : undefined} + > + +
+ {!isEditing && ( + + )} + ); } From e1ee086db8b2838b1c0a741e1c0d11d0becab8dc Mon Sep 17 00:00:00 2001 From: Scott Cazan Date: Wed, 4 Mar 2026 10:00:18 +0100 Subject: [PATCH 12/14] Fix refresh token race condition (#703) Fixes race conditions in a single call that uses batched requests by caching the auth call so it is re-used for the whole of the batch. Helps to address token refresh issues for users. --- services/api/src/middlewares/withAuthenticated.ts | 8 +++----- .../middlewares/withAuthenticatedPlatformAdmin.ts | 5 ++--- services/api/src/supabase/server.ts | 14 +++++++++++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/services/api/src/middlewares/withAuthenticated.ts b/services/api/src/middlewares/withAuthenticated.ts index 9e976a1e1..d1419f235 100644 --- a/services/api/src/middlewares/withAuthenticated.ts +++ b/services/api/src/middlewares/withAuthenticated.ts @@ -2,7 +2,7 @@ import { cache } from '@op/cache'; import { getAllowListUser } from '@op/common'; import { TRPCError } from '@trpc/server'; -import { createSBAdminClient } from '../supabase/server'; +import { getCachedAuthUser } from '../supabase/server'; import type { MiddlewareBuilderBase, TContextWithUser } from '../types'; import { verifyAuthentication } from '../utils/verifyAuthentication'; @@ -10,8 +10,7 @@ const withAuthenticated: MiddlewareBuilderBase = async ({ ctx, next, }) => { - const supabase = createSBAdminClient(ctx); - const data = await supabase.auth.getUser(); + const data = await getCachedAuthUser(ctx); const user = verifyAuthentication(data); @@ -45,8 +44,7 @@ const withAuthenticated: MiddlewareBuilderBase = async ({ export const withAuthenticatedAdmin: MiddlewareBuilderBase< TContextWithUser > = async ({ ctx, next }) => { - const supabase = createSBAdminClient(ctx); - const data = await supabase.auth.getUser(); + const data = await getCachedAuthUser(ctx); const user = verifyAuthentication(data, true); diff --git a/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts b/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts index af9abeb15..075176714 100644 --- a/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts +++ b/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts @@ -1,6 +1,6 @@ import { UnauthorizedError, isUserEmailPlatformAdmin } from '@op/common'; -import { createSBAdminClient } from '../supabase/server'; +import { getCachedAuthUser } from '../supabase/server'; import type { MiddlewareBuilderBase, TContextWithUser } from '../types'; import { verifyAuthentication } from '../utils/verifyAuthentication'; @@ -10,8 +10,7 @@ import { verifyAuthentication } from '../utils/verifyAuthentication'; export const withAuthenticatedPlatformAdmin: MiddlewareBuilderBase< TContextWithUser > = async ({ ctx, next }) => { - const supabase = createSBAdminClient(ctx); - const data = await supabase.auth.getUser(); + const data = await getCachedAuthUser(ctx); const user = verifyAuthentication(data); diff --git a/services/api/src/supabase/server.ts b/services/api/src/supabase/server.ts index bd277f180..3127300b4 100644 --- a/services/api/src/supabase/server.ts +++ b/services/api/src/supabase/server.ts @@ -3,7 +3,7 @@ import { OPURLConfig, cookieOptionsDomain } from '@op/core'; import { logger } from '@op/logging'; import { createServerClient } from '@op/supabase/lib'; -import type { CookieOptions } from '@op/supabase/lib'; +import type { CookieOptions, UserResponse } from '@op/supabase/lib'; import type { Database } from '@op/supabase/types'; import 'server-only'; @@ -11,6 +11,18 @@ import type { TContext } from '../types'; const useUrl = OPURLConfig('APP'); +const authUserCache = new WeakMap>(); + +export function getCachedAuthUser(ctx: TContext): Promise { + let promise = authUserCache.get(ctx); + if (!promise) { + const supabase = createSBAdminClient(ctx); + promise = supabase.auth.getUser(); + authUserCache.set(ctx, promise); + } + return promise; +} + export const createSBAdminClient = (ctx: TContext) => { return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, From 7dcb195f2e8d037214acd8315f117ddcf6a774ef Mon Sep 17 00:00:00 2001 From: Nour Malaeb Date: Wed, 4 Mar 2026 14:57:06 -0500 Subject: [PATCH 13/14] Rubric editor UI (#696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the rubric editor UI to the Process Builder, allowing process owners to define review criteria (scored, yes/no, text response) that reviewers use to evaluate proposals. ## What's included - **Rubric template utilities** (`rubricTemplate.ts`) — pure functions for creating, reading, and immutably updating rubric JSON Schema, with typed helpers to eliminate boilerplate - **Criterion cards** — collapsible accordion cards with drag-and-drop reordering, inline name/description editing, type switching via radio group, and scored config (max points, score labels) - **Scored config caching** — switching away from "scored" type and back restores the previous scale/labels - **Delete confirmation modal** — follows the same pattern as phases editor - **Empty state** — matches the proposal categories pattern (`LuLeaf` icon) - **Debounced auto-save** to the process builder store - **Live participant preview** of the rubric form - **Translation keys** added to all 5 language files - Gated behind `rubric_builder` feature flag ## Not included - Autosave-on-initial-load fix (planned for a separate branch) - Field-level validation on blur ## Testing Use the [preview deployment](https://app-git-rubric-editor-ui-oneproject.vercel.app/). Make sure you have a phase with "Enable proposal review" toggled on. --- .../app/src/components/ConfirmDeleteModal.tsx | 52 ++ .../ProcessBuilder/ProcessBuilderHeader.tsx | 11 +- .../general/OverviewSectionForm.tsx | 12 +- .../general/PhasesSectionContent.tsx | 55 +-- .../ProposalCategoriesSectionContent.tsx | 9 +- .../stepContent/rubric/CriteriaSection.tsx | 28 +- .../rubric/RubricCriterionCard.tsx | 402 ++++++++++++++++ .../rubric/RubricEditorContent.tsx | 396 ++++++++++++++++ .../rubric/RubricEditorSkeleton.tsx | 44 ++ .../rubric/RubricParticipantPreview.tsx | 2 +- .../rubric/rubricCriterionRegistry.tsx | 41 ++ .../template/TemplateEditorContent.tsx | 10 +- .../stores/useProcessBuilderStore.ts | 31 +- .../components/decisions/proposalTemplate.ts | 102 ++-- .../components/decisions/rubricTemplate.ts | 445 ++++++++++++++++++ .../src/components/decisions/templateUtils.ts | 202 ++++++++ apps/app/src/lib/i18n/dictionaries/bn.json | 32 +- apps/app/src/lib/i18n/dictionaries/en.json | 32 +- apps/app/src/lib/i18n/dictionaries/es.json | 32 +- apps/app/src/lib/i18n/dictionaries/fr.json | 32 +- apps/app/src/lib/i18n/dictionaries/pt.json | 32 +- packages/ui/src/components/RadioGroup.tsx | 4 +- services/api/src/encoders/decision.ts | 53 ++- .../instances/updateDecisionInstance.test.ts | 4 +- 24 files changed, 1905 insertions(+), 158 deletions(-) create mode 100644 apps/app/src/components/ConfirmDeleteModal.tsx create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorSkeleton.tsx create mode 100644 apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx create mode 100644 apps/app/src/components/decisions/rubricTemplate.ts create mode 100644 apps/app/src/components/decisions/templateUtils.ts diff --git a/apps/app/src/components/ConfirmDeleteModal.tsx b/apps/app/src/components/ConfirmDeleteModal.tsx new file mode 100644 index 000000000..19c782373 --- /dev/null +++ b/apps/app/src/components/ConfirmDeleteModal.tsx @@ -0,0 +1,52 @@ +import { Button } from '@op/ui/Button'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; + +import { useTranslations } from '@/lib/i18n'; + +export function ConfirmDeleteModal({ + isOpen, + title, + message, + onConfirm, + onCancel, +}: { + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + onCancel: () => void; +}) { + const t = useTranslations(); + return ( + { + if (!open) { + onCancel(); + } + }} + > + {title} + +

{message}

+
+ + + + +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx index a58b4e1b2..223213e9b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderHeader.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useFeatureFlag } from '@/hooks/useFeatureFlag'; import { trpc } from '@op/api/client'; import { ProcessStatus } from '@op/api/encoders'; import { Button } from '@op/ui/Button'; @@ -87,6 +88,7 @@ const ProcessBuilderHeaderContent = ({ slug?: string; }) => { const t = useTranslations(); + const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const router = useRouter(); const navigationConfig = useNavigationConfig(instanceId); const { visibleSteps, currentStep, setStep } = @@ -239,7 +241,9 @@ const ProcessBuilderHeaderContent = ({ className="flex h-full cursor-pointer items-center gap-2" > {t(step.labelKey)} - {step.id === 'rubric' && } + {step.id === 'rubric' && !rubricBuilderEnabled && ( + + )} ))} @@ -252,6 +256,7 @@ const ProcessBuilderHeaderContent = ({ const MobileSidebar = ({ instanceId }: { instanceId: string }) => { const t = useTranslations(); + const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); const navigationConfig = useNavigationConfig(instanceId); const { visibleSteps, currentStep, setStep } = useProcessNavigation(navigationConfig); @@ -293,7 +298,9 @@ const MobileSidebar = ({ instanceId }: { instanceId: string }) => { className="flex h-8 items-center gap-2 bg-transparent selected:bg-neutral-offWhite" > {t(step.labelKey)} - {step.id === 'rubric' && } + {step.id === 'rubric' && !rubricBuilderEnabled && ( + + )} ))} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx index 3a26ad90b..32c4c0a9a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/OverviewSectionForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; import { useDebouncedCallback } from '@op/hooks'; import { SelectItem } from '@op/ui/Select'; import { useEffect, useRef } from 'react'; @@ -9,13 +10,12 @@ import { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; import type { TranslateFn } from '@/lib/i18n'; +import { SaveStatusIndicator } from '@/components/decisions/ProcessBuilder/components/SaveStatusIndicator'; +import { ToggleRow } from '@/components/decisions/ProcessBuilder/components/ToggleRow'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; +import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; import { getFieldErrorMessage, useAppForm } from '@/components/form/utils'; -import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; -import { ToggleRow } from '../../components/ToggleRow'; -import type { SectionProps } from '../../contentRegistry'; -import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; - const AUTOSAVE_DEBOUNCE_MS = 1000; const createOverviewValidator = (t: TranslateFn) => @@ -78,7 +78,7 @@ export function OverviewSectionForm({ const utils = trpc.useUtils(); const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); - const isDraft = instance.status === 'draft'; + const isDraft = instance.status === ProcessStatus.DRAFT; // Store: used as a localStorage buffer for non-draft edits only const instanceData = useProcessBuilderStore( diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx index 72087d1f6..586bc221a 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/PhasesSectionContent.tsx @@ -2,6 +2,7 @@ import { parseDate } from '@internationalized/date'; import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; import type { PhaseDefinition, PhaseRules } from '@op/api/encoders'; import { useDebouncedCallback } from '@op/hooks'; import { @@ -15,7 +16,6 @@ import { import { AutoSizeInput } from '@op/ui/AutoSizeInput'; import { Button } from '@op/ui/Button'; import { DatePicker } from '@op/ui/DatePicker'; -import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; import type { Key } from '@op/ui/RAC'; import { DisclosureStateContext } from '@op/ui/RAC'; import { DragHandle, Sortable } from '@op/ui/Sortable'; @@ -33,12 +33,12 @@ import { import { useTranslations } from '@/lib/i18n'; +import { ConfirmDeleteModal } from '@/components/ConfirmDeleteModal'; import { RichTextEditorWithToolbar } from '@/components/RichTextEditor/RichTextEditorWithToolbar'; - -import { SaveStatusIndicator } from '../../components/SaveStatusIndicator'; -import { ToggleRow } from '../../components/ToggleRow'; -import type { SectionProps } from '../../contentRegistry'; -import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +import { SaveStatusIndicator } from '@/components/decisions/ProcessBuilder/components/SaveStatusIndicator'; +import { ToggleRow } from '@/components/decisions/ProcessBuilder/components/ToggleRow'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; +import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; const AUTOSAVE_DEBOUNCE_MS = 1000; @@ -49,7 +49,7 @@ export function PhasesSectionContent({ const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); const instancePhases = instance.instanceData?.phases; const templatePhases = instance.process?.processSchema?.phases; - const isDraft = instance.status === 'draft'; + const isDraft = instance.status === ProcessStatus.DRAFT; // Store: used as a localStorage buffer for non-draft edits only const storePhases = useProcessBuilderStore( @@ -489,40 +489,15 @@ export const PhaseEditor = ({ {t('Add phase')} - { - if (!open) { - setPhaseToDelete(null); - } - }} - > - {t('Delete phase')} - -

- {t( - 'Are you sure you want to delete this phase? This action cannot be undone.', - )} -

-
- - - - -
+ title={t('Delete phase')} + message={t( + 'Are you sure you want to delete this phase? This action cannot be undone.', + )} + onConfirm={confirmRemovePhase} + onCancel={() => setPhaseToDelete(null)} + /> ); }; diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx index e35f42944..03722b305 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/general/ProposalCategoriesSectionContent.tsx @@ -1,6 +1,7 @@ 'use client'; import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; import type { ProposalCategory } from '@op/common'; import { useDebouncedCallback } from '@op/hooks'; import { Button } from '@op/ui/Button'; @@ -14,9 +15,9 @@ import { LuLeaf, LuPencil, LuPlus, LuTrash2 } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; -import { ensureLockedFields } from '../../../proposalTemplate'; -import type { SectionProps } from '../../contentRegistry'; -import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; +import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; +import { ensureLockedFields } from '@/components/decisions/proposalTemplate'; const AUTOSAVE_DEBOUNCE_MS = 1000; const CATEGORY_TITLE_MAX_LENGTH = 40; @@ -36,7 +37,7 @@ export function ProposalCategoriesSectionContent({ // Fetch server data for seeding const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); - const isDraft = instance.status === 'draft'; + const isDraft = instance.status === ProcessStatus.DRAFT; const serverConfig = instance.instanceData?.config; const storeData = useProcessBuilderStore( diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx index 509c08ac0..396fa85f5 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/CriteriaSection.tsx @@ -5,32 +5,30 @@ import { Suspense } from 'react'; import { useTranslations } from '@/lib/i18n'; -import type { SectionProps } from '../../contentRegistry'; +import ErrorBoundary from '@/components/ErrorBoundary'; +import { ErrorMessage } from '@/components/ErrorMessage'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; + import { CodeAnimation } from './RubricComingSoonAnimation'; -import { RubricParticipantPreview } from './RubricParticipantPreview'; -import { DUMMY_RUBRIC_TEMPLATE } from './dummyRubricTemplate'; +import { RubricEditorContent } from './RubricEditorContent'; +import { RubricEditorSkeleton } from './RubricEditorSkeleton'; export default function CriteriaSection(props: SectionProps) { return ( - - - + }> + }> + + + ); } -function CriteriaSectionContent(_props: SectionProps) { +function CriteriaSectionContent(props: SectionProps) { const t = useTranslations(); const rubricBuilderEnabled = useFeatureFlag('rubric_builder'); if (rubricBuilderEnabled) { - return ( -
- {/* Left panel — placeholder for the future rubric builder */} -
- - -
- ); + return ; } return ( diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx new file mode 100644 index 000000000..8dfa445ad --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricCriterionCard.tsx @@ -0,0 +1,402 @@ +'use client'; + +import { + AccordionContent, + AccordionHeader, + AccordionIndicator, + AccordionTrigger, +} from '@op/ui/Accordion'; +import { Button } from '@op/ui/Button'; +import { NumberField } from '@op/ui/NumberField'; +import { Radio, RadioGroup } from '@op/ui/RadioGroup'; +import { DragHandle } from '@op/ui/Sortable'; +import type { SortableItemControls } from '@op/ui/Sortable'; +import { TextField } from '@op/ui/TextField'; +import { useState } from 'react'; +import { LuChevronRight, LuGripVertical, LuTrash2 } from 'react-icons/lu'; + +import { useTranslations } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n/routing'; + +import type { + CriterionView, + RubricCriterionType, +} from '@/components/decisions/rubricTemplate'; + +import { + CRITERION_TYPES, + CRITERION_TYPE_REGISTRY, +} from './rubricCriterionRegistry'; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface RubricCriterionCardProps { + criterion: CriterionView; + /** 1-based display index for the header (e.g. "Criterion 1") */ + index: number; + errors?: TranslationKey[]; + controls?: SortableItemControls; + onRemove?: (criterionId: string) => void; + onUpdateLabel?: (criterionId: string, label: string) => void; + onUpdateDescription?: (criterionId: string, description: string) => void; + onChangeType?: (criterionId: string, newType: RubricCriterionType) => void; + onUpdateMaxPoints?: (criterionId: string, maxPoints: number) => void; + onUpdateScoreLabel?: ( + criterionId: string, + scoreValue: number, + label: string, + ) => void; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +/** + * A collapsible accordion card for a single rubric criterion. + * + * Built directly with Accordion primitives (not FieldConfigCard) to match + * the mockup: static "Criterion N" header, separate name/description fields + * in the body, and a criterion type radio selector. + * + * Must be rendered inside an `` which is inside an ``. + */ +export function RubricCriterionCard({ + criterion, + index, + errors = [], + controls, + onRemove, + onUpdateLabel, + onUpdateDescription, + onChangeType, + onUpdateMaxPoints, + onUpdateScoreLabel, +}: RubricCriterionCardProps) { + const t = useTranslations(); + + return ( + <> + {/* Header: drag handle + chevron + "Criterion N" + delete button */} + + {controls && ( + + )} + + + + {t('Criterion {number}', { number: index })} + + + {criterion.label || t('New criterion')} + + + + {onRemove && ( + + )} + + + {/* Collapsible body */} + +
+
+ {/* Criterion name */} + onUpdateLabel?.(criterion.id, value)} + inputProps={{ + placeholder: t('e.g., Goal Alignment'), + }} + description={t( + 'Add a short, clear name for this evaluation criterion', + )} + /> + + {/* Description */} + onUpdateDescription?.(criterion.id, value)} + textareaProps={{ + placeholder: t( + "What should reviewers evaluate? Be specific about what you're looking for.", + ), + className: 'min-h-24 resize-none', + }} + description={t('Help reviewers understand what to assess')} + /> + +
+ + {/* Criterion type radio selector */} + onChangeType?.(criterion.id, newType)} + /> + + {/* Type-specific configuration */} + {criterion.criterionType === 'scored' && ( + <> +
+ + onUpdateMaxPoints?.(criterion.id, max) + } + onUpdateScoreLabel={(scoreValue, label) => + onUpdateScoreLabel?.(criterion.id, scoreValue, label) + } + /> + + )} + + {/* Validation errors */} + {errors.length > 0 && ( +
+ {errors.map((error) => ( +

+ {t(error)} +

+ ))} +
+ )} +
+
+ + ); +} + +// --------------------------------------------------------------------------- +// Criterion type radio selector +// --------------------------------------------------------------------------- + +function CriterionTypeSelector({ + value, + onChange, +}: { + value: RubricCriterionType; + onChange: (type: RubricCriterionType) => void; +}) { + const t = useTranslations(); + + return ( + onChange(newValue as RubricCriterionType)} + orientation="vertical" + labelClassName="text-base" + > + {CRITERION_TYPES.map((type) => { + const entry = CRITERION_TYPE_REGISTRY[type]; + return ( + +
+ {t(entry.labelKey)} +

+ {t(entry.descriptionKey)} +

+
+
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// Scored criterion config (max points + score labels) +// --------------------------------------------------------------------------- + +function ScoredCriterionConfig({ + criterion, + onUpdateMaxPoints, + onUpdateScoreLabel, +}: { + criterion: CriterionView; + onUpdateMaxPoints: (max: number) => void; + onUpdateScoreLabel: (scoreValue: number, label: string) => void; +}) { + const t = useTranslations(); + const max = criterion.maxPoints ?? 5; + + // Cache descriptions that would be lost when maxPoints decreases. + // Key is 1-based score value, value is the description text. + // Cache persists until user navigates away from this criterion card. + const [cachedDescriptions, setCachedDescriptions] = useState< + Record + >({}); + + const handleMaxPointsChange = (value: number | null) => { + if (value === null || value < 2) { + return; + } + + const newMax = value; + + if (newMax < max) { + // Decreasing - cache descriptions that will be removed + const toCache: Record = { ...cachedDescriptions }; + for (let i = newMax + 1; i <= max; i++) { + const label = criterion.scoreLabels[i - 1]; // scoreLabels is 0-indexed + if (label) { + toCache[i] = label; + } + } + setCachedDescriptions(toCache); + } else if (newMax > max) { + // Increasing - restore cached descriptions after update + const labelsToRestore: Array<{ score: number; label: string }> = []; + for (let i = max + 1; i <= newMax; i++) { + const cached = cachedDescriptions[i]; + if (cached) { + labelsToRestore.push({ score: i, label: cached }); + } + } + + // Clear restored items from cache + if (labelsToRestore.length > 0) { + const newCache = { ...cachedDescriptions }; + labelsToRestore.forEach(({ score }) => delete newCache[score]); + setCachedDescriptions(newCache); + + // Restore labels after state update + setTimeout(() => { + labelsToRestore.forEach(({ score, label }) => { + onUpdateScoreLabel(score, label); + }); + }, 0); + } + } + + onUpdateMaxPoints(newMax); + }; + + return ( +
+ + +
+

+ {t('Define what each score means')} +

+

+ {t( + 'Help reviewers score consistently by describing what each point value represents', + )} +

+
+ {criterion.scoreLabels.map((_, i) => { + const revIdx = criterion.scoreLabels.length - 1 - i; + const label = criterion.scoreLabels[revIdx]!; + const scoreValue = max - i; + return ( +
+ + {scoreValue} + + onUpdateScoreLabel(scoreValue, value)} + textareaProps={{ + placeholder: t('Describe what earns {number} points...', { + number: scoreValue, + }), + }} + className="w-full" + /> +
+ ); + })} +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Shared criterion badges (type + points) +// --------------------------------------------------------------------------- + +function CriterionBadges({ criterion }: { criterion: CriterionView }) { + const t = useTranslations(); + return ( + + + {t(CRITERION_TYPE_REGISTRY[criterion.criterionType].labelKey)} + + {criterion.criterionType === 'scored' && criterion.maxPoints && ( + + {criterion.maxPoints} {t('pts')} + + )} + + ); +} + +// --------------------------------------------------------------------------- +// Drag preview +// --------------------------------------------------------------------------- + +export function RubricCriterionDragPreview({ + criterion, + index, +}: { + criterion: CriterionView; + index: number; +}) { + const t = useTranslations(); + return ( +
+
+ +
+ + + {t('Criterion {number}', { number: index })} + + + {criterion.label || t('New criterion')} + + +
+ +
+
+ ); +} + +export function RubricCriterionDropIndicator() { + return ( +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx new file mode 100644 index 000000000..981e04d9e --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorContent.tsx @@ -0,0 +1,396 @@ +'use client'; + +import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; +import type { RubricTemplateSchema } from '@op/common/client'; +import { useDebouncedCallback } from '@op/hooks'; +import { Accordion, AccordionItem } from '@op/ui/Accordion'; +import { Button } from '@op/ui/Button'; +import { EmptyState } from '@op/ui/EmptyState'; +import { Header2 } from '@op/ui/Header'; +import type { Key } from '@op/ui/RAC'; +import { Sortable } from '@op/ui/Sortable'; +import { cn } from '@op/ui/utils'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { LuLeaf, LuPlus } from 'react-icons/lu'; +import { useShallow } from 'zustand/react/shallow'; + +import { useTranslations } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n/routing'; + +import { ConfirmDeleteModal } from '@/components/ConfirmDeleteModal'; +import { SaveStatusIndicator } from '@/components/decisions/ProcessBuilder/components/SaveStatusIndicator'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; +import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; +import type { + CriterionView, + RubricCriterionType, +} from '@/components/decisions/rubricTemplate'; +import { + addCriterion, + changeCriterionType, + createEmptyRubricTemplate, + getCriteria, + getCriterionErrors, + getCriterionSchema, + getCriterionType, + removeCriterion, + reorderCriteria, + updateCriterionDescription, + updateCriterionJsonSchema, + updateCriterionLabel, + updateScoreLabel, + updateScoredMaxPoints, +} from '@/components/decisions/rubricTemplate'; + +import { + RubricCriterionCard, + RubricCriterionDragPreview, + RubricCriterionDropIndicator, +} from './RubricCriterionCard'; +import { RubricParticipantPreview } from './RubricParticipantPreview'; + +const AUTOSAVE_DEBOUNCE_MS = 1000; + +export function RubricEditorContent({ + decisionProfileId, + instanceId, +}: SectionProps) { + const t = useTranslations(); + + // Load instance data from the backend + const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); + const isDraft = instance.status === ProcessStatus.DRAFT; + const utils = trpc.useUtils(); + const instanceData = instance.instanceData; + + const initialTemplate = useMemo(() => { + const saved = instanceData?.rubricTemplate; + if (saved && Object.keys(saved.properties ?? {}).length > 0) { + return saved as RubricTemplateSchema; + } + return createEmptyRubricTemplate(); + }, [instanceData?.rubricTemplate]); + + const [template, setTemplate] = + useState(initialTemplate); + const isInitialLoadRef = useRef(true); + + // Validation: "show on blur, clear on change" + const [criterionErrors, setCriterionErrors] = useState< + Map + >(new Map()); + + // Accordion expansion state + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + + // Delete confirmation modal + const [criterionToDelete, setCriterionToDelete] = useState( + null, + ); + + // Cache scored config so switching type and back doesn't lose score labels + const scoredConfigCacheRef = useRef< + Map + >(new Map()); + + const { setRubricTemplateSchema, setSaveStatus, markSaved, getSaveState } = + useProcessBuilderStore( + useShallow((s) => ({ + setRubricTemplateSchema: s.setRubricTemplateSchema, + setSaveStatus: s.setSaveStatus, + markSaved: s.markSaved, + getSaveState: s.getSaveState, + })), + ); + const saveState = getSaveState(decisionProfileId); + + const debouncedSaveRef = useRef<() => boolean>(null); + const updateInstance = trpc.decision.updateDecisionInstance.useMutation({ + onSuccess: () => markSaved(decisionProfileId), + onError: () => setSaveStatus(decisionProfileId, 'error'), + onSettled: () => { + if (debouncedSaveRef.current?.()) { + return; + } + void utils.decision.getInstance.invalidate({ instanceId }); + }, + }); + + // Derive criterion views from the template + const criteria = useMemo(() => getCriteria(template), [template]); + const criteriaIndexMap = useMemo( + () => new Map(criteria.map((c, i) => [c.id, i])), + [criteria], + ); + + // TODO: Extract this debounced auto-save pattern into a shared useAutoSave() hook + // (same pattern is used in TemplateEditorContent and useProposalDraft) + const debouncedSave = useDebouncedCallback( + (updatedTemplate: RubricTemplateSchema) => { + setRubricTemplateSchema(decisionProfileId, updatedTemplate); + + if (isDraft) { + updateInstance.mutate({ + instanceId, + rubricTemplate: updatedTemplate, + }); + } else { + markSaved(decisionProfileId); + } + }, + AUTOSAVE_DEBOUNCE_MS, + ); + debouncedSaveRef.current = () => debouncedSave.isPending(); + + // Trigger debounced save when template changes (skip initial load) + useEffect(() => { + if (isInitialLoadRef.current) { + isInitialLoadRef.current = false; + return; + } + + setSaveStatus(decisionProfileId, 'saving'); + debouncedSave(template); + }, [template, decisionProfileId, setSaveStatus, debouncedSave]); + + // --- Handlers --- + + const handleAddCriterion = useCallback(() => { + const criterionId = crypto.randomUUID().slice(0, 8); + const label = t('New criterion'); + setTemplate((prev) => addCriterion(prev, criterionId, 'scored', label)); + setExpandedKeys((prev) => new Set([...prev, criterionId])); + }, [t]); + + const handleRemoveCriterion = useCallback((criterionId: string) => { + setCriterionToDelete(criterionId); + }, []); + + const confirmRemoveCriterion = useCallback(() => { + if (!criterionToDelete) { + return; + } + setTemplate((prev) => removeCriterion(prev, criterionToDelete)); + setCriterionErrors((prev) => { + const next = new Map(prev); + next.delete(criterionToDelete); + return next; + }); + scoredConfigCacheRef.current.delete(criterionToDelete); + setCriterionToDelete(null); + }, [criterionToDelete]); + + const handleReorderCriteria = useCallback((newItems: CriterionView[]) => { + setTemplate((prev) => + reorderCriteria( + prev, + newItems.map((item) => item.id), + ), + ); + }, []); + + const handleUpdateLabel = useCallback( + (criterionId: string, label: string) => { + setTemplate((prev) => updateCriterionLabel(prev, criterionId, label)); + }, + [], + ); + + const handleUpdateDescription = useCallback( + (criterionId: string, description: string) => { + setTemplate((prev) => + updateCriterionDescription(prev, criterionId, description || undefined), + ); + }, + [], + ); + + const handleChangeType = useCallback( + (criterionId: string, newType: RubricCriterionType) => { + setTemplate((prev) => { + // Stash scored config before switching away from scored + if (getCriterionType(prev, criterionId) === 'scored') { + const schema = getCriterionSchema(prev, criterionId); + const oneOfEntries = (schema?.oneOf ?? []).filter( + (e): e is { const: number; title: string } => + typeof e === 'object' && + e !== null && + 'const' in e && + typeof (e as Record).const === 'number' && + 'title' in e && + typeof (e as Record).title === 'string', + ); + scoredConfigCacheRef.current.set(criterionId, { + maximum: schema?.maximum ?? 5, + oneOf: oneOfEntries, + }); + } + + // Change the type (rebuilds schema from scratch) + let updated = changeCriterionType(prev, criterionId, newType); + + // Restore cached scored config when switching back to scored + if (newType === 'scored') { + const cached = scoredConfigCacheRef.current.get(criterionId); + if (cached) { + updated = updateCriterionJsonSchema(updated, criterionId, { + maximum: cached.maximum, + oneOf: cached.oneOf, + }); + } + } + + return updated; + }); + }, + [], + ); + + const handleUpdateMaxPoints = useCallback( + (criterionId: string, maxPoints: number) => { + setTemplate((prev) => + updateScoredMaxPoints(prev, criterionId, maxPoints), + ); + }, + [], + ); + + const handleUpdateScoreLabel = useCallback( + (criterionId: string, scoreValue: number, label: string) => { + setTemplate((prev) => + updateScoreLabel(prev, criterionId, scoreValue, label), + ); + }, + [], + ); + + return ( +
+
+
+
+ + {t('Review Criteria')} + + +
+ + {criteria.length === 0 ? ( +
+ }> +
+ + {t('No review criteria yet')} + + + {t( + 'Add criteria to help reviewers evaluate proposals consistently', + )} + + +
+
+
+ ) : ( + <> + + criterion.label} + className="gap-3" + renderDragPreview={(items) => { + const item = items[0]; + if (!item) { + return null; + } + const idx = criteriaIndexMap.get(item.id) ?? 0; + return ( + + ); + }} + renderDropIndicator={RubricCriterionDropIndicator} + aria-label={t('Rubric criteria')} + > + {(criterion, controls) => { + const idx = criteriaIndexMap.get(criterion.id) ?? 0; + const snapshotErrors = + criterionErrors.get(criterion.id) ?? []; + const liveErrors = getCriterionErrors(criterion); + const displayedErrors = snapshotErrors.filter((e) => + liveErrors.includes(e), + ); + + return ( + + + + ); + }} + + + + + + )} +
+
+ + + + setCriterionToDelete(null)} + /> +
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorSkeleton.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorSkeleton.tsx new file mode 100644 index 000000000..42d9e1295 --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricEditorSkeleton.tsx @@ -0,0 +1,44 @@ +import { Skeleton } from '@op/ui/Skeleton'; + +/** + * Skeleton loading state for the rubric criteria editor. + */ +export function RubricEditorSkeleton() { + return ( +
+ {/* Main content skeleton */} +
+
+
+ + +
+ + {/* Criterion card skeletons */} + {[1, 2, 3].map((i) => ( +
+
+ + + + +
+
+ ))} + + +
+
+ + {/* Preview skeleton */} +
+
+ + + + +
+
+
+ ); +} diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx index 647d1725f..04f04262b 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricParticipantPreview.tsx @@ -30,7 +30,7 @@ export function RubricParticipantPreview({ } return ( -