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/.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/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/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx b/apps/app/src/app/[locale]/(no-header)/decisions/[slug]/edit/page.tsx index e01b17a1c..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 @@ -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,27 +28,19 @@ 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 ( -
+
-
+
{ className="w-full sm:w-auto" href={`/decisions/${decision.slug}`} onPress={() => setNavigatingId(decision.id)} + isLoading={isNavigating} > - {isNavigating ? : t('Participate')} + {t('Participate')} 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/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/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 && ( + + )} + ); } 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/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/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/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; }) => (
; - 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/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..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 } = @@ -151,13 +153,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, }); } }; @@ -245,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 && ( + + )} ))} @@ -258,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); @@ -299,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/ProcessBuilderStoreInitializer.tsx b/apps/app/src/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer.tsx index 6bc828346..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,14 +29,27 @@ export function ProcessBuilderStoreInitializer({ isDraft, }: { decisionProfileId: string; - serverData: FormInstanceData; + serverData: ProcessBuilderInstanceData; isDraft: boolean; }) { 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]; @@ -45,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/navigationConfig.ts b/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts index dcab95c36..ba553f7a3 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/navigationConfig.ts @@ -1,5 +1,6 @@ // Process Builder Navigation Configuration // Steps and sections are defined here with `as const` - types are derived from this config +import type { TranslationKey } from '@/lib/i18n'; export const STEPS = [ { id: 'general', labelKey: 'General' }, @@ -25,7 +26,7 @@ export const SECTIONS_BY_STEP = { ], } as const satisfies Record< StepId, - readonly { id: string; labelKey: string }[] + readonly { id: string; labelKey: TranslationKey }[] >; // 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 277ca2777..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,23 +1,24 @@ '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'; 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: (key: string) => string) => +const createOverviewValidator = (t: TranslateFn) => z.object({ stewardProfileId: z .string({ message: t('Select a steward for this process') }) @@ -77,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( @@ -128,9 +129,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 +167,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), @@ -176,7 +179,7 @@ export function OverviewSectionForm({ }); 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..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( @@ -149,7 +149,7 @@ export function PhasesSectionContent({ }; return ( -
+

{t('Phases')}

{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 081003f4f..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,15 +37,15 @@ 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( (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 +53,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 +89,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 +106,7 @@ export function ProposalCategoriesSectionContent({ requireCategorySelection: data.requireCategorySelection, }); mutation.proposalTemplate = syncedTemplate; - setProposalTemplate(decisionProfileId, syncedTemplate); + setProposalTemplateSchema(decisionProfileId, syncedTemplate); } if (isDraft) { @@ -196,7 +197,7 @@ export function ProposalCategoriesSectionContent({ const showList = categories.length > 0; return ( -
+
{t('Proposal Categories')} 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..396fa85f5 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,35 @@ +'use client'; + +import { useFeatureFlag } from '@/hooks/useFeatureFlag'; +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 { RubricEditorContent } from './RubricEditorContent'; +import { RubricEditorSkeleton } from './RubricEditorSkeleton'; + +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 ; + } 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/RubricFormPreviewRenderer.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx new file mode 100644 index 000000000..58b1185fc --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/RubricFormPreviewRenderer.tsx @@ -0,0 +1,154 @@ +'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'; + +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'; +} + +/** 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)) { + 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..04f04262b --- /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..a5dfe217d --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/dummyRubricTemplate.ts @@ -0,0 +1,102 @@ +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', + 'innovation__rationale', + 'feasibility', + 'feasibility__rationale', + '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' }, + ], + }, + innovation__rationale: { + type: 'string', + title: 'Reason(s) and Insight(s)', + 'x-format': 'long-text', + }, + 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' }, + ], + }, + feasibility__rationale: { + type: 'string', + title: 'Reason(s) and Insight(s)', + 'x-format': 'long-text', + }, + 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', + 'innovation__rationale', + 'feasibility', + 'feasibility__rationale', + 'meetsEligibility', + ], +}; diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx new file mode 100644 index 000000000..c34a3f2ea --- /dev/null +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/rubric/rubricCriterionRegistry.tsx @@ -0,0 +1,41 @@ +import type { TranslationKey } from '@/lib/i18n/routing'; + +import type { RubricCriterionType } from '@/components/decisions/rubricTemplate'; + +/** + * Display metadata for each rubric criterion type. + */ +interface CriterionTypeRegistryEntry { + /** Translation key for the type label */ + labelKey: TranslationKey; + /** Translation key for a short description shown in the radio selector */ + descriptionKey: TranslationKey; +} + +export const CRITERION_TYPE_REGISTRY: Record< + RubricCriterionType, + CriterionTypeRegistryEntry +> = { + scored: { + labelKey: 'Rating Scale', + descriptionKey: + 'Reviewers select a number with descriptions for each point value', + }, + yes_no: { + labelKey: 'Yes/No', + descriptionKey: 'Simple binary assessment', + }, + long_text: { + labelKey: 'Text response only', + descriptionKey: 'No score, just written feedback', + }, +}; + +/** + * Ordered list of criterion types for the radio selector. + */ +export const CRITERION_TYPES: RubricCriterionType[] = [ + 'scored', + 'yes_no', + 'long_text', +]; 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..780551bb8 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, @@ -9,8 +10,8 @@ import { ToggleButton } from '@op/ui/ToggleButton'; import { useRef } from 'react'; import { useTranslations } from '@/lib/i18n'; +import type { TranslationKey } from '@/lib/i18n'; -import type { XFormatPropertySchema } from '../../../proposalEditor/compileProposalSchema'; import type { FieldView } from '../../../proposalTemplate'; import { getFieldConfigComponent, @@ -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/ParticipantPreview.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/ParticipantPreview.tsx index d620486f1..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 = { @@ -22,7 +20,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..60e0e2d33 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorContent.tsx @@ -1,7 +1,12 @@ 'use client'; import { trpc } from '@op/api/client'; +import { ProcessStatus } from '@op/api/encoders'; 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 +19,8 @@ import { LuAlignLeft, LuChevronDown, LuHash } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; -import type { - ProposalTemplateSchema, - XFormatPropertySchema, -} from '../../../proposalEditor/compileProposalSchema'; +import type { SectionProps } from '@/components/decisions/ProcessBuilder/contentRegistry'; +import { useProcessBuilderStore } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore'; import { type FieldType, type FieldView, @@ -33,9 +36,8 @@ import { setFieldRequired, updateFieldDescription, updateFieldLabel, -} from '../../../proposalTemplate'; -import type { SectionProps } from '../../contentRegistry'; -import { useProcessBuilderStore } from '../../stores/useProcessBuilderStore'; +} from '@/components/decisions/proposalTemplate'; + import { AddFieldMenu } from './AddFieldMenu'; import { BudgetFieldConfig } from './BudgetFieldConfig'; import { @@ -62,7 +64,7 @@ export function TemplateEditorContent({ // Load instance data from the backend const [instance] = trpc.decision.getInstance.useSuspenseQuery({ instanceId }); - const isDraft = instance.status === 'draft'; + const isDraft = instance.status === ProcessStatus.DRAFT; const utils = trpc.useUtils(); const instanceData = instance.instanceData; @@ -70,10 +72,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 +130,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 +203,7 @@ export function TemplateEditorContent({ categories, requireCategorySelection, }); - setProposalTemplate(decisionProfileId, normalized); + setProposalTemplateSchema(decisionProfileId, normalized); if (isDraft) { updateInstance.mutate({ @@ -351,7 +353,7 @@ export function TemplateEditorContent({
- + {t('Proposal template')}

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..0fa2ea39d 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,10 @@ +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 { 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/stores/useProcessBuilderStore.ts b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts index 784eda2e7..eddfab236 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/stores/useProcessBuilderStore.ts @@ -31,55 +31,29 @@ * - `saveStates[decisionId]` - UI save indicator state */ import type { InstanceData, InstancePhaseData } from '@op/api/encoders'; -import type { ProposalCategory, ProposalTemplateSchema } from '@op/common'; +import type { ProposalTemplateSchema, RubricTemplateSchema } 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 - extends Omit, 'proposalTemplate'> { - /** Instance name (stored in processInstances.name, not instanceData) */ +export interface ProcessBuilderInstanceData + extends Omit, 'proposalTemplate' | 'rubricTemplate'> { + // 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 generic JSON Schema types with specific ones 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; + rubricTemplate?: RubricTemplateSchema; } // ============ UI-only Types ============ @@ -95,16 +69,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,14 +94,23 @@ interface ProcessBuilderState { ) => InstancePhaseData | undefined; // Actions for proposal template - setProposalTemplate: ( + setProposalTemplateSchema: ( decisionId: string, template: ProposalTemplateSchema, ) => void; - getProposalTemplate: ( + getProposalTemplateSchema: ( decisionId: string, ) => ProposalTemplateSchema | undefined; + // Actions for rubric template + setRubricTemplateSchema: ( + decisionId: string, + template: RubricTemplateSchema, + ) => void; + getRubricTemplateSchema: ( + decisionId: string, + ) => RubricTemplateSchema | undefined; + // Actions for save state setSaveStatus: (decisionId: string, status: SaveStatus) => void; markSaved: (decisionId: string) => void; @@ -146,15 +131,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 +186,7 @@ export const useProcessBuilderStore = create()( }, // Proposal template actions - setProposalTemplate: (decisionId, template) => + setProposalTemplateSchema: (decisionId, template) => set((state) => ({ instances: { ...state.instances, @@ -208,9 +197,24 @@ export const useProcessBuilderStore = create()( }, })), - getProposalTemplate: (decisionId) => + getProposalTemplateSchema: (decisionId) => get().instances[decisionId]?.proposalTemplate, + // Rubric template actions + setRubricTemplateSchema: (decisionId, template) => + set((state) => ({ + instances: { + ...state.instances, + [decisionId]: { + ...state.instances[decisionId], + rubricTemplate: template, + }, + }, + })), + + getRubricTemplateSchema: (decisionId) => + get().instances[decisionId]?.rubricTemplate, + // Save state actions setSaveStatus: (decisionId, status) => set((state) => ({ diff --git a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts index 2e5d3bd07..65660b59f 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts +++ b/apps/app/src/components/decisions/ProcessBuilder/validation/processBuilderValidation.ts @@ -1,9 +1,11 @@ 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 { FormInstanceData } from '../stores/useProcessBuilderStore'; +import type { ProcessBuilderInstanceData } from '../stores/useProcessBuilderStore'; // ============ Types ============ @@ -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 ============ @@ -37,9 +39,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; } @@ -61,8 +67,8 @@ const SECTION_VALIDATORS: Record = { interface ChecklistItem { id: string; - labelKey: string; - validate: (data: FormInstanceData | undefined) => boolean; + labelKey: TranslationKey; + validate: (data: ProcessBuilderInstanceData | undefined) => boolean; } /** @@ -121,7 +127,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/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/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 b083dfe99..8ac6836c8 100644 --- a/apps/app/src/components/decisions/ProposalContentRenderer.tsx +++ b/apps/app/src/components/decisions/ProposalContentRenderer.tsx @@ -1,20 +1,24 @@ '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). */ 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; } /** @@ -28,6 +32,7 @@ interface ProposalContentRendererProps { export function ProposalContentRenderer({ proposalTemplate, htmlContent, + translatedMeta, }: ProposalContentRendererProps) { const dynamicFields = useMemo(() => { if (!proposalTemplate) { @@ -47,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]} /> ))} @@ -60,25 +67,30 @@ export function ProposalContentRenderer({ function ViewField({ field, html, + translatedTitle, + translatedDescription, }: { - field: ProposalFieldDescriptor; + 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 12ad9e6ef..092eaf587 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -6,8 +6,9 @@ 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'; import { Header1 } from '@op/ui/Header'; import { Link } from '@op/ui/Link'; @@ -16,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'; @@ -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']; @@ -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/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..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, @@ -13,7 +14,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 +24,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 +47,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 +55,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,10 +65,10 @@ 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, + t: TranslateFn, preview: boolean, onEditorFocus?: (editor: Editor) => void, onEditorBlur?: (editor: Editor) => void, @@ -303,7 +280,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/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/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/components/decisions/proposalTemplate.ts b/apps/app/src/components/decisions/proposalTemplate.ts index 832cfdab2..33042a46c 100644 --- a/apps/app/src/components/decisions/proposalTemplate.ts +++ b/apps/app/src/components/decisions/proposalTemplate.ts @@ -1,29 +1,44 @@ /** * 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). * + * Generic JSON Schema operations are delegated to `templateUtils.ts`. + * This file adds proposal-specific logic: field types, options, locked fields, + * and category management. + * * No separate uiSchema is stored — everything lives in the JSON Schema itself * via vendor extensions (`x-*` properties). */ import { - ProposalTemplateSchema, + type ProposalTemplateSchema, SYSTEM_FIELD_KEYS, buildCategorySchema, parseSchemaOptions, schemaHasOptions, } from '@op/common/client'; -// --------------------------------------------------------------------------- -// Core types -// --------------------------------------------------------------------------- +import { + addProperty, + getPropertyDescription, + getPropertyLabel, + getPropertyOrder, + isPropertyRequired, + removeProperty, + reorderProperties, + setPropertyRequired, + updatePropertyDescription, + updatePropertyLabel, +} from './templateUtils'; + +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. */ @@ -92,13 +107,15 @@ function asSchema(def: unknown): ProposalTemplateSchema | undefined { } export function getFieldOrder(template: ProposalTemplateSchema): string[] { - return (template['x-field-order'] as string[] | undefined) ?? []; + return getPropertyOrder(template); } export function getFieldSchema( template: ProposalTemplateSchema, fieldId: string, ): ProposalTemplateSchema | undefined { + // Use asSchema to handle legacy schemas where properties may not match + // the XFormatPropertySchema type exactly. const props = template.properties; if (!props) { return undefined; @@ -125,27 +142,21 @@ export function getFieldLabel( template: ProposalTemplateSchema, fieldId: string, ): string { - const schema = getFieldSchema(template, fieldId); - return (schema?.title as string | undefined) ?? ''; + return getPropertyLabel(template, fieldId); } export function getFieldDescription( template: ProposalTemplateSchema, fieldId: string, ): string | undefined { - const schema = getFieldSchema(template, fieldId); - return schema?.description; + return getPropertyDescription(template, fieldId); } export function isFieldRequired( template: ProposalTemplateSchema, fieldId: string, ): boolean { - const required = template.required; - if (!Array.isArray(required)) { - return false; - } - return required.includes(fieldId); + return isPropertyRequired(template, fieldId); } export function getFieldOptions( @@ -260,7 +271,7 @@ export function getFieldErrors(field: FieldView): string[] { } // --------------------------------------------------------------------------- -// Immutable mutators — each returns a new ProposalTemplate +// Immutable mutators — each returns a new template // --------------------------------------------------------------------------- export function addField( @@ -270,42 +281,21 @@ export function addField( label: string, ): ProposalTemplateSchema { const jsonSchema = { ...createFieldJsonSchema(type), title: label }; - const order = getFieldOrder(template); - - return { - ...template, - properties: { - ...template.properties, - [fieldId]: jsonSchema, - }, - 'x-field-order': [...order, fieldId], - }; + return addProperty(template, fieldId, jsonSchema); } export function removeField( template: ProposalTemplateSchema, fieldId: string, ): ProposalTemplateSchema { - const { [fieldId]: _removed, ...restProps } = template.properties ?? {}; - const order = getFieldOrder(template).filter((id) => id !== fieldId); - const required = (template.required ?? []).filter((id) => id !== fieldId); - - return { - ...template, - properties: restProps, - required: required.length > 0 ? required : undefined, - 'x-field-order': order, - }; + return removeProperty(template, fieldId); } export function reorderFields( template: ProposalTemplateSchema, newOrder: string[], ): ProposalTemplateSchema { - return { - ...template, - 'x-field-order': newOrder, - }; + return reorderProperties(template, newOrder); } export function updateFieldLabel( @@ -313,18 +303,7 @@ export function updateFieldLabel( fieldId: string, label: string, ): ProposalTemplateSchema { - const schema = getFieldSchema(template, fieldId); - if (!schema) { - return template; - } - - return { - ...template, - properties: { - ...template.properties, - [fieldId]: { ...schema, title: label }, - }, - }; + return updatePropertyLabel(template, fieldId, label); } export function updateFieldDescription( @@ -332,25 +311,7 @@ export function updateFieldDescription( fieldId: string, description: string | undefined, ): ProposalTemplateSchema { - const schema = getFieldSchema(template, fieldId); - if (!schema) { - return template; - } - - const updated = { ...schema }; - if (description) { - updated.description = description; - } else { - delete updated.description; - } - - return { - ...template, - properties: { - ...template.properties, - [fieldId]: updated, - }, - }; + return updatePropertyDescription(template, fieldId, description); } export function setFieldRequired( @@ -358,14 +319,7 @@ export function setFieldRequired( fieldId: string, required: boolean, ): ProposalTemplateSchema { - const current = template.required ?? []; - const filtered = current.filter((id) => id !== fieldId); - const next = required ? [...filtered, fieldId] : filtered; - - return { - ...template, - required: next.length > 0 ? next : undefined, - }; + return setPropertyRequired(template, fieldId, required); } // --------------------------------------------------------------------------- diff --git a/apps/app/src/components/decisions/rubricTemplate.ts b/apps/app/src/components/decisions/rubricTemplate.ts new file mode 100644 index 000000000..ba863ebc8 --- /dev/null +++ b/apps/app/src/components/decisions/rubricTemplate.ts @@ -0,0 +1,445 @@ +/** + * Rubric Template — JSON Schema utilities. + * + * Uses `RubricTemplateSchema` from `@op/common`. Field ordering is stored as a + * top-level `x-field-order` array. Per-criterion widget selection is driven by + * `x-format` on each property (consumed by the renderer's rubric field logic). + * + * Generic JSON Schema operations are delegated to `templateUtils.ts`. + * This file adds rubric-specific logic: criterion types, scored config, and + * type inference from schema shape. + */ +import type { + RubricTemplateSchema, + XFormatPropertySchema, +} from '@op/common/client'; +import type { JSONSchema7 } from 'json-schema'; + +import type { TranslationKey } from '@/lib/i18n/routing'; + +import { + addProperty, + getPropertyDescription, + getPropertyLabel, + getPropertyOrder, + getPropertySchema, + isPropertyRequired, + isSchemaObject, + removeProperty, + reorderProperties, + setPropertyRequired, + updateProperty, + updatePropertyDescription, + updatePropertyLabel, +} from './templateUtils'; + +export type { RubricTemplateSchema }; + +// --------------------------------------------------------------------------- +// Criterion types +// --------------------------------------------------------------------------- + +export type RubricCriterionType = 'scored' | 'yes_no' | 'long_text'; + +/** + * Flat read-only view of a single rubric criterion, derived from the template. + * Gives builder/renderer code a friendly object instead of requiring + * multiple reader calls per criterion. + */ +export interface CriterionView { + id: string; + criterionType: RubricCriterionType; + label: string; + description?: string; + required: boolean; + /** Maximum points for scored criteria. */ + maxPoints?: number; + /** Labels for each score level (index 0 = score 1, ascending). Scored criteria only. */ + scoreLabels: string[]; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_POINTS = 5; + +/** + * Extract oneOf entries as typed `JSONSchema7[]`, filtering out boolean + * definitions. + */ +function getOneOfEntries(schema: XFormatPropertySchema): JSONSchema7[] { + if (!Array.isArray(schema.oneOf)) { + return []; + } + return schema.oneOf.filter(isSchemaObject); +} + +// --------------------------------------------------------------------------- +// Criterion type ↔ JSON Schema mapping +// --------------------------------------------------------------------------- + +/** + * Create the JSON Schema for a given criterion type. + */ +export function createCriterionJsonSchema( + type: RubricCriterionType, +): XFormatPropertySchema { + switch (type) { + case 'scored': { + const max = DEFAULT_MAX_POINTS; + const oneOf = Array.from({ length: max }, (_, i) => ({ + const: i + 1, + title: '', + })); + return { + type: 'integer', + 'x-format': 'dropdown', + minimum: 1, + maximum: max, + oneOf, + }; + } + case 'yes_no': + return { + type: 'string', + 'x-format': 'dropdown', + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }; + case 'long_text': + return { + type: 'string', + 'x-format': 'long-text', + }; + } +} + +// --------------------------------------------------------------------------- +// Type inference from schema shape +// --------------------------------------------------------------------------- + +/** + * Infer the `RubricCriterionType` from a raw JSON Schema property. + */ +export function inferCriterionType( + schema: XFormatPropertySchema, +): RubricCriterionType | undefined { + const xFormat = schema['x-format']; + + if (xFormat === 'long-text') { + return 'long_text'; + } + + if (xFormat === 'dropdown') { + if (schema.type === 'integer' && schema.maximum != null) { + return 'scored'; + } + + if (schema.type === 'string') { + const values = getOneOfEntries(schema).map((e) => e.const); + if ( + values.length === 2 && + values.includes('yes') && + values.includes('no') + ) { + return 'yes_no'; + } + } + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// Readers (delegating to shared utils where possible) +// --------------------------------------------------------------------------- + +export function getCriterionOrder(template: RubricTemplateSchema): string[] { + return getPropertyOrder(template); +} + +export function getCriterionSchema( + template: RubricTemplateSchema, + criterionId: string, +): XFormatPropertySchema | undefined { + return getPropertySchema(template, criterionId); +} + +export function getCriterionType( + template: RubricTemplateSchema, + criterionId: string, +): RubricCriterionType | undefined { + const schema = getCriterionSchema(template, criterionId); + if (!schema) { + return undefined; + } + return inferCriterionType(schema); +} + +export function getCriterionLabel( + template: RubricTemplateSchema, + criterionId: string, +): string { + return getPropertyLabel(template, criterionId); +} + +export function getCriterionDescription( + template: RubricTemplateSchema, + criterionId: string, +): string | undefined { + return getPropertyDescription(template, criterionId); +} + +export function isCriterionRequired( + template: RubricTemplateSchema, + criterionId: string, +): boolean { + return isPropertyRequired(template, criterionId); +} + +export function getCriterionMaxPoints( + template: RubricTemplateSchema, + criterionId: string, +): number | undefined { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer') { + return undefined; + } + return schema.maximum; +} + +export function getCriterionScoreLabels( + template: RubricTemplateSchema, + criterionId: string, +): string[] { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer') { + return []; + } + return getOneOfEntries(schema) + .filter( + (e): e is JSONSchema7 & { const: number; title: string } => + typeof e.const === 'number' && typeof e.title === 'string', + ) + .sort((a, b) => a.const - b.const) + .map((e) => e.title); +} + +// --------------------------------------------------------------------------- +// Composite readers +// --------------------------------------------------------------------------- + +export function getCriterion( + template: RubricTemplateSchema, + criterionId: string, +): CriterionView | undefined { + const criterionType = getCriterionType(template, criterionId); + if (!criterionType) { + return undefined; + } + + return { + id: criterionId, + criterionType, + label: getCriterionLabel(template, criterionId), + description: getCriterionDescription(template, criterionId), + required: isCriterionRequired(template, criterionId), + maxPoints: getCriterionMaxPoints(template, criterionId), + scoreLabels: getCriterionScoreLabels(template, criterionId), + }; +} + +export function getCriteria(template: RubricTemplateSchema): CriterionView[] { + const order = getCriterionOrder(template); + const criteria: CriterionView[] = []; + for (const id of order) { + const criterion = getCriterion(template, id); + if (criterion) { + criteria.push(criterion); + } + } + return criteria; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Returns translation keys for validation errors on a criterion. + * Pass each key through `t()` in the UI layer. + */ +export function getCriterionErrors(criterion: CriterionView): TranslationKey[] { + const errors: TranslationKey[] = []; + + if (!criterion.label.trim()) { + errors.push('Criterion label is required'); + } + + if (criterion.criterionType === 'scored') { + if (criterion.scoreLabels.some((l) => !l.trim())) { + errors.push('Score labels cannot be empty'); + } + } + + return errors; +} + +// --------------------------------------------------------------------------- +// Immutable mutators — each returns a new template +// --------------------------------------------------------------------------- + +export function addCriterion( + template: RubricTemplateSchema, + criterionId: string, + type: RubricCriterionType, + label: string, +): RubricTemplateSchema { + const jsonSchema = { ...createCriterionJsonSchema(type), title: label }; + return addProperty(template, criterionId, jsonSchema); +} + +export function removeCriterion( + template: RubricTemplateSchema, + criterionId: string, +): RubricTemplateSchema { + return removeProperty(template, criterionId); +} + +export function reorderCriteria( + template: RubricTemplateSchema, + newOrder: string[], +): RubricTemplateSchema { + return reorderProperties(template, newOrder); +} + +export function updateCriterionLabel( + template: RubricTemplateSchema, + criterionId: string, + label: string, +): RubricTemplateSchema { + return updatePropertyLabel(template, criterionId, label); +} + +export function updateCriterionDescription( + template: RubricTemplateSchema, + criterionId: string, + description: string | undefined, +): RubricTemplateSchema { + return updatePropertyDescription(template, criterionId, description); +} + +export function setCriterionRequired( + template: RubricTemplateSchema, + criterionId: string, + required: boolean, +): RubricTemplateSchema { + return setPropertyRequired(template, criterionId, required); +} + +/** + * Change a criterion's type while preserving its label, description, and + * required status. The schema is rebuilt from scratch for the new type. + */ +export function changeCriterionType( + template: RubricTemplateSchema, + criterionId: string, + newType: RubricCriterionType, +): RubricTemplateSchema { + return updateProperty(template, criterionId, (existing) => { + const newSchema: XFormatPropertySchema = { + ...createCriterionJsonSchema(newType), + title: existing.title, + }; + if (existing.description) { + newSchema.description = existing.description; + } + return newSchema; + }); +} + +/** + * Low-level updater for the raw JSON Schema of a criterion. + * Used for restoring cached scored config, etc. + */ +export function updateCriterionJsonSchema( + template: RubricTemplateSchema, + criterionId: string, + updates: Partial, +): RubricTemplateSchema { + return updateProperty(template, criterionId, (s) => ({ ...s, ...updates })); +} + +/** + * Update the maximum points for a scored criterion. + * Rebuilds the `oneOf` array to match the new max, preserving existing + * labels where possible and generating defaults for new levels. + */ +export function updateScoredMaxPoints( + template: RubricTemplateSchema, + criterionId: string, + newMax: number, +): RubricTemplateSchema { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer') { + return template; + } + + const clampedMax = Math.max(2, newMax); + const existingLabels = getCriterionScoreLabels(template, criterionId); + + const oneOf = Array.from({ length: clampedMax }, (_, i) => ({ + const: i + 1, + title: existingLabels[i] ?? '', + })); + + return updateProperty(template, criterionId, (s) => ({ + ...s, + maximum: clampedMax, + oneOf, + })); +} + +/** + * Update a single score label for a scored criterion. + * `scoreValue` is the 1-based score (the `.const` in the oneOf entry), + * not an array index. + */ +export function updateScoreLabel( + template: RubricTemplateSchema, + criterionId: string, + scoreValue: number, + label: string, +): RubricTemplateSchema { + const schema = getCriterionSchema(template, criterionId); + if (!schema || schema.type !== 'integer' || !Array.isArray(schema.oneOf)) { + return template; + } + + const oneOf = schema.oneOf.map((entry) => { + if (isSchemaObject(entry) && entry.const === scoreValue) { + return { ...entry, title: label }; + } + return entry; + }); + + return updateProperty(template, criterionId, (s) => ({ ...s, oneOf })); +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Creates an empty rubric template with no criteria. + */ +export function createEmptyRubricTemplate(): RubricTemplateSchema { + return { + type: 'object', + properties: {}, + 'x-field-order': [], + }; +} diff --git a/apps/app/src/components/decisions/templateUtils.ts b/apps/app/src/components/decisions/templateUtils.ts new file mode 100644 index 000000000..a8bdaf9d3 --- /dev/null +++ b/apps/app/src/components/decisions/templateUtils.ts @@ -0,0 +1,202 @@ +/** + * Shared JSON Schema template utilities. + * + * Provides generic, type-safe operations for templates that store their + * properties in a JSON Schema `properties` object and maintain ordering + * via a top-level `x-field-order` array. Both proposal and rubric + * templates share this structure. + * + * Domain-specific logic (e.g. scored criteria, locked proposal fields) + * lives in `rubricTemplate.ts` and `proposalTemplate.ts` respectively. + */ +import type { XFormatPropertySchema } from '@op/common/client'; +import type { JSONSchema7 } from 'json-schema'; + +// --------------------------------------------------------------------------- +// Base template shape +// --------------------------------------------------------------------------- + +/** + * Minimal contract that both `ProposalTemplateSchema` and + * `RubricTemplateSchema` satisfy. All shared utilities are generic + * over this interface. + */ +export interface BaseTemplateSchema extends JSONSchema7 { + [key: string]: unknown; + properties?: Record; + 'x-field-order'?: string[]; +} + +// --------------------------------------------------------------------------- +// Type guards +// --------------------------------------------------------------------------- + +/** + * Type guard that narrows a `JSONSchema7Definition` (which is + * `JSONSchema7 | boolean`) to `JSONSchema7`. + */ +export function isSchemaObject( + entry: JSONSchema7 | boolean, +): entry is JSONSchema7 { + return typeof entry !== 'boolean'; +} + +// --------------------------------------------------------------------------- +// Readers +// --------------------------------------------------------------------------- + +/** Returns the ordered list of property IDs in the template. */ +export function getPropertyOrder( + template: T, +): string[] { + return template['x-field-order'] ?? []; +} + +/** Returns the raw JSON Schema for a single property. */ +export function getPropertySchema( + template: T, + propertyId: string, +): XFormatPropertySchema | undefined { + return template.properties?.[propertyId]; +} + +/** Returns the `title` of a property, falling back to `''`. */ +export function getPropertyLabel( + template: T, + propertyId: string, +): string { + return getPropertySchema(template, propertyId)?.title ?? ''; +} + +/** Returns the `description` of a property. */ +export function getPropertyDescription( + template: T, + propertyId: string, +): string | undefined { + return getPropertySchema(template, propertyId)?.description; +} + +/** Returns whether a property is listed in the `required` array. */ +export function isPropertyRequired( + template: T, + propertyId: string, +): boolean { + return template.required?.includes(propertyId) ?? false; +} + +// --------------------------------------------------------------------------- +// Immutable updater (internal building block) +// --------------------------------------------------------------------------- + +/** + * Update a single property's schema within a template. Returns the + * template unchanged if the property doesn't exist. + */ +export function updateProperty( + template: T, + propertyId: string, + updater: (schema: XFormatPropertySchema) => XFormatPropertySchema, +): T { + const schema = getPropertySchema(template, propertyId); + if (!schema) { + return template; + } + return { + ...template, + properties: { + ...template.properties, + [propertyId]: updater(schema), + }, + }; +} + +// --------------------------------------------------------------------------- +// Immutable mutators +// --------------------------------------------------------------------------- + +/** Add a property with the given schema and append it to the order. */ +export function addProperty( + template: T, + propertyId: string, + schema: XFormatPropertySchema, +): T { + const order = getPropertyOrder(template); + return { + ...template, + properties: { + ...template.properties, + [propertyId]: schema, + }, + 'x-field-order': [...order, propertyId], + }; +} + +/** Remove a property from the template, including from `required` and order. */ +export function removeProperty( + template: T, + propertyId: string, +): T { + const { [propertyId]: _removed, ...restProps } = template.properties ?? {}; + const order = getPropertyOrder(template).filter((id) => id !== propertyId); + const required = (template.required ?? []).filter((id) => id !== propertyId); + + return { + ...template, + properties: restProps, + required: required.length > 0 ? required : undefined, + 'x-field-order': order, + }; +} + +/** Replace the property order with the given array. */ +export function reorderProperties( + template: T, + newOrder: string[], +): T { + return { + ...template, + 'x-field-order': newOrder, + }; +} + +/** Update the `title` of a property. */ +export function updatePropertyLabel( + template: T, + propertyId: string, + label: string, +): T { + return updateProperty(template, propertyId, (s) => ({ ...s, title: label })); +} + +/** Update the `description` of a property. Removes the key when empty. */ +export function updatePropertyDescription( + template: T, + propertyId: string, + description: string | undefined, +): T { + return updateProperty(template, propertyId, (s) => { + const updated = { ...s }; + if (description) { + updated.description = description; + } else { + delete updated.description; + } + return updated; + }); +} + +/** Set or unset a property as required. */ +export function setPropertyRequired( + template: T, + propertyId: string, + required: boolean, +): T { + const current = template.required ?? []; + const filtered = current.filter((id) => id !== propertyId); + const next = required ? [...filtered, propertyId] : filtered; + + return { + ...template, + required: next.length > 0 ? next : undefined, + }; +} 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/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 3d98e69f1..a2e9e19fc 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...": "মুছে ফেলা হচ্ছে...", @@ -495,6 +490,7 @@ "Role updated successfully": "ভূমিকা সফলভাবে আপডেট করা হয়েছে", "Failed to update role": "ভূমিকা আপডেট করতে ব্যর্থ", "Members list": "সদস্যদের তালিকা", + "Participants list": "অংশগ্রহণকারীদের তালিকা", "Overview": "সারসংক্ষেপ", "Proposal Template": "প্রস্তাব টেমপ্লেট", "Review Rubric": "পর্যালোচনা রুব্রিক", @@ -715,19 +711,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 +734,6 @@ "Delete draft": "খসড়া মুছুন", "Decision deleted successfully": "সিদ্ধান্ত সফলভাবে মুছে ফেলা হয়েছে", "Failed to delete decision": "সিদ্ধান্ত মুছে ফেলতে ব্যর্থ", - "Deleting...": "মুছে ফেলা হচ্ছে...", "A bridge to the": "একটি সেতু", "new economy.": "নতুন অর্থনীতির দিকে।", "Connect with your network.": "আপনার নেটওয়ার্কের সাথে সংযুক্ত হন।", @@ -766,10 +761,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 +774,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 +797,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,11 +827,93 @@ "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": "বিভাগ পাওয়া যায়নি" + "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": "হ্যাঁ/না", + "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": "প্লেসহোল্ডার", + "Add criterion": "মানদণ্ড যোগ করুন", + "Describe what earns {number} points...": "{number} পয়েন্ট পেতে কী প্রয়োজন তা বর্ণনা করুন...", + "Define what each score means": "প্রতিটি স্কোরের অর্থ নির্ধারণ করুন", + "Max points": "সর্বোচ্চ পয়েন্ট", + "Minimum is 2": "সর্বনিম্ন 2", + "New criterion": "নতুন মানদণ্ড", + "Remove criterion": "মানদণ্ড সরান", + "Rubric criteria": "রুব্রিক মানদণ্ড", + "Help reviewers score consistently by describing what each point value represents": "প্রতিটি পয়েন্ট মান কী বোঝায় তা বর্ণনা করে পর্যালোচকদের ধারাবাহিকভাবে স্কোর করতে সাহায্য করুন", + "Review Criteria": "পর্যালোচনার মানদণ্ড", + "No review criteria yet": "এখনো কোনো পর্যালোচনার মানদণ্ড নেই", + "Add criteria to help reviewers evaluate proposals consistently": "পর্যালোচকদের ধারাবাহিকভাবে প্রস্তাব মূল্যায়নে সহায়তার জন্য মানদণ্ড যোগ করুন", + "Add your first criterion": "আপনার প্রথম মানদণ্ড যোগ করুন", + "Criterion {number}": "মানদণ্ড {number}", + "Criterion name": "মানদণ্ডের নাম", + "Criterion label is required": "মানদণ্ডের লেবেল আবশ্যক", + "Drag to reorder criterion": "মানদণ্ড পুনর্বিন্যাস করতে টানুন", + "e.g., Goal Alignment": "যেমন, লক্ষ্য সারিবদ্ধকরণ", + "Add a short, clear name for this evaluation criterion": "এই মূল্যায়ন মানদণ্ডের জন্য একটি সংক্ষিপ্ত, স্পষ্ট নাম যোগ করুন", + "What should reviewers evaluate? Be specific about what you're looking for.": "পর্যালোচকদের কী মূল্যায়ন করা উচিত? আপনি কী খুঁজছেন সে সম্পর্কে সুনির্দিষ্ট হন।", + "Help reviewers understand what to assess": "পর্যালোচকদের কী মূল্যায়ন করতে হবে তা বুঝতে সাহায্য করুন", + "How should reviewers score this?": "পর্যালোচকরা কীভাবে এটি স্কোর করবেন?", + "Rating Scale": "রেটিং স্কেল", + "Reviewers select a number with descriptions for each point value": "পর্যালোচকরা প্রতিটি পয়েন্ট মানের বিবরণ সহ একটি সংখ্যা নির্বাচন করেন", + "Simple binary assessment": "সহজ বাইনারি মূল্যায়ন", + "Text response only": "শুধুমাত্র পাঠ্য প্রতিক্রিয়া", + "No score, just written feedback": "কোনো স্কোর নেই, শুধু লিখিত প্রতিক্রিয়া", + "Score labels cannot be empty": "স্কোর লেবেল খালি রাখা যাবে না", + "Delete criterion": "মানদণ্ড মুছুন", + "Are you sure you want to delete this criterion? This action cannot be undone.": "আপনি কি নিশ্চিত যে এই মানদণ্ডটি মুছতে চান? এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না।" } diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index b23bc7816..888431b00 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", @@ -488,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", @@ -731,7 +727,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 +754,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 +767,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 +797,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,11 +820,93 @@ "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" + "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", + "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", + "Add criterion": "Add criterion", + "Describe what earns {number} points...": "Describe what earns {number} points...", + "Define what each score means": "Define what each score means", + "Max points": "Max points", + "Minimum is 2": "Minimum is 2", + "New criterion": "New criterion", + "Remove criterion": "Remove criterion", + "Rubric criteria": "Rubric criteria", + "Help reviewers score consistently by describing what each point value represents": "Help reviewers score consistently by describing what each point value represents", + "Review Criteria": "Review Criteria", + "No review criteria yet": "No review criteria yet", + "Add criteria to help reviewers evaluate proposals consistently": "Add criteria to help reviewers evaluate proposals consistently", + "Add your first criterion": "Add your first criterion", + "Criterion {number}": "Criterion {number}", + "Criterion name": "Criterion name", + "Criterion label is required": "Criterion label is required", + "Drag to reorder criterion": "Drag to reorder criterion", + "e.g., Goal Alignment": "e.g., Goal Alignment", + "Add a short, clear name for this evaluation criterion": "Add a short, clear name for this evaluation criterion", + "What should reviewers evaluate? Be specific about what you're looking for.": "What should reviewers evaluate? Be specific about what you're looking for.", + "Help reviewers understand what to assess": "Help reviewers understand what to assess", + "How should reviewers score this?": "How should reviewers score this?", + "Rating Scale": "Rating Scale", + "Reviewers select a number with descriptions for each point value": "Reviewers select a number with descriptions for each point value", + "Simple binary assessment": "Simple binary assessment", + "Text response only": "Text response only", + "No score, just written feedback": "No score, just written feedback", + "Score labels cannot be empty": "Score labels cannot be empty", + "Delete criterion": "Delete criterion", + "Are you sure you want to delete this criterion? This action cannot be undone.": "Are you sure you want to delete this criterion? This action cannot be undone." } diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index 6656e4e3c..b40d0ebd7 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", @@ -487,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", @@ -730,7 +726,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 +753,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 +766,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 +796,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,11 +819,94 @@ "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" + "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", + "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", + "Add criterion": "Agregar criterio", + "Describe what earns {number} points...": "Describa qué merece {number} puntos...", + "Define what each score means": "Defina qué significa cada puntuación", + "Max points": "Puntos máximos", + "Minimum is 2": "El mínimo es 2", + "New criterion": "Nuevo criterio", + "Remove criterion": "Eliminar criterio", + "Rubric criteria": "Criterios de rúbrica", + "Help reviewers score consistently by describing what each point value represents": "Ayude a los revisores a puntuar de manera consistente describiendo lo que representa cada valor de punto", + "Review Criteria": "Criterios de revisión", + "No review criteria yet": "Aún no hay criterios de revisión", + "Add criteria to help reviewers evaluate proposals consistently": "Agregue criterios para ayudar a los revisores a evaluar propuestas de manera consistente", + "Add your first criterion": "Agregue su primer criterio", + "Criterion {number}": "Criterio {number}", + "Criterion name": "Nombre del criterio", + "Criterion label is required": "La etiqueta del criterio es obligatoria", + "Drag to reorder criterion": "Arrastrar para reordenar criterio", + "e.g., Goal Alignment": "p. ej., Alineación con objetivos", + "Add a short, clear name for this evaluation criterion": "Agregue un nombre corto y claro para este criterio de evaluación", + "What should reviewers evaluate? Be specific about what you're looking for.": "¿Qué deben evaluar los revisores? Sea específico sobre lo que busca.", + "Help reviewers understand what to assess": "Ayude a los revisores a entender qué evaluar", + "How should reviewers score this?": "¿Cómo deben puntuar esto los revisores?", + "Rating Scale": "Escala de calificación", + "Reviewers select a number with descriptions for each point value": "Los revisores seleccionan un número con descripciones para cada valor de punto", + "Simple binary assessment": "Evaluación binaria simple", + "Text response only": "Solo respuesta de texto", + "No score, just written feedback": "Sin puntuación, solo retroalimentación escrita", + "Score labels cannot be empty": "Las etiquetas de puntuación no pueden estar vacías", + "Delete criterion": "Eliminar criterio", + "Are you sure you want to delete this criterion? This action cannot be undone.": "¿Está seguro de que desea eliminar este criterio? Esta acción no se puede deshacer." } diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index 0579c5856..a3d5f3b7f 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", @@ -488,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", @@ -730,7 +726,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 +753,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 +766,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 +796,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,11 +819,94 @@ "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" + "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", + "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é", + "Add criterion": "Ajouter un critère", + "Describe what earns {number} points...": "Décrivez ce qui mérite {number} points...", + "Define what each score means": "Définissez ce que signifie chaque note", + "Max points": "Points maximum", + "Minimum is 2": "Le minimum est 2", + "New criterion": "Nouveau critère", + "Remove criterion": "Supprimer le critère", + "Rubric criteria": "Critères de la grille", + "Help reviewers score consistently by describing what each point value represents": "Aidez les évaluateurs à noter de manière cohérente en décrivant ce que représente chaque valeur de point", + "Review Criteria": "Critères d'évaluation", + "No review criteria yet": "Pas encore de critères d'évaluation", + "Add criteria to help reviewers evaluate proposals consistently": "Ajoutez des critères pour aider les évaluateurs à évaluer les propositions de manière cohérente", + "Add your first criterion": "Ajoutez votre premier critère", + "Criterion {number}": "Critère {number}", + "Criterion name": "Nom du critère", + "Criterion label is required": "Le libellé du critère est obligatoire", + "Drag to reorder criterion": "Glisser pour réordonner le critère", + "e.g., Goal Alignment": "p. ex., Alignement des objectifs", + "Add a short, clear name for this evaluation criterion": "Ajoutez un nom court et clair pour ce critère d'évaluation", + "What should reviewers evaluate? Be specific about what you're looking for.": "Que doivent évaluer les évaluateurs ? Soyez précis sur ce que vous recherchez.", + "Help reviewers understand what to assess": "Aidez les évaluateurs à comprendre quoi évaluer", + "How should reviewers score this?": "Comment les évaluateurs doivent-ils noter ceci ?", + "Rating Scale": "Échelle de notation", + "Reviewers select a number with descriptions for each point value": "Les évaluateurs sélectionnent un nombre avec des descriptions pour chaque valeur de point", + "Simple binary assessment": "Évaluation binaire simple", + "Text response only": "Réponse textuelle uniquement", + "No score, just written feedback": "Pas de note, uniquement des commentaires écrits", + "Score labels cannot be empty": "Les libellés des notes ne peuvent pas être vides", + "Delete criterion": "Supprimer le critère", + "Are you sure you want to delete this criterion? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce critère ? Cette action est irréversible." } diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index e95f8204d..04059e535 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", @@ -488,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", @@ -726,7 +722,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 +749,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 +762,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 +792,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,11 +815,98 @@ "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" + "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", + "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", + "Add criterion": "Adicionar critério", + "Describe what earns {number} points...": "Descreva o que merece {number} pontos...", + "Define what each score means": "Defina o que cada nota significa", + "Max points": "Pontos máximos", + "Minimum is 2": "O mínimo é 2", + "New criterion": "Novo critério", + "Remove criterion": "Remover critério", + "Rubric criteria": "Critérios da rubrica", + "Help reviewers score consistently by describing what each point value represents": "Ajude os avaliadores a pontuar de forma consistente descrevendo o que cada valor de ponto representa", + "Review Criteria": "Critérios de avaliação", + "No review criteria yet": "Ainda não há critérios de avaliação", + "Add criteria to help reviewers evaluate proposals consistently": "Adicione critérios para ajudar os avaliadores a avaliar propostas de forma consistente", + "Add your first criterion": "Adicione seu primeiro critério", + "Criterion {number}": "Critério {number}", + "Criterion name": "Nome do critério", + "Criterion label is required": "O rótulo do critério é obrigatório", + "Drag to reorder criterion": "Arrastar para reordenar critério", + "e.g., Goal Alignment": "p. ex., Alinhamento de objetivos", + "Add a short, clear name for this evaluation criterion": "Adicione um nome curto e claro para este critério de avaliação", + "What should reviewers evaluate? Be specific about what you're looking for.": "O que os avaliadores devem avaliar? Seja específico sobre o que procura.", + "Help reviewers understand what to assess": "Ajude os avaliadores a entender o que avaliar", + "How should reviewers score this?": "Como os avaliadores devem pontuar isto?", + "Rating Scale": "Escala de classificação", + "Reviewers select a number with descriptions for each point value": "Os avaliadores selecionam um número com descrições para cada valor de ponto", + "Simple binary assessment": "Avaliação binária simples", + "Text response only": "Apenas resposta de texto", + "No score, just written feedback": "Sem pontuação, apenas feedback escrito", + "Score labels cannot be empty": "Os rótulos das notas não podem estar vazios", + "Delete criterion": "Excluir critério", + "Are you sure you want to delete this criterion? This action cannot be undone.": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita." } 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]); }; diff --git a/packages/common/src/client.ts b/packages/common/src/client.ts index e6509819a..36feca2cc 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 { @@ -24,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/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' 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/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/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 01e99de1d..a853fc6ae 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,47 @@ 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/src/components/RadioGroup.tsx b/packages/ui/src/components/RadioGroup.tsx index 679358e66..8916f4c97 100644 --- a/packages/ui/src/components/RadioGroup.tsx +++ b/packages/ui/src/components/RadioGroup.tsx @@ -12,6 +12,7 @@ import type { } from 'react-aria-components'; import { tv } from 'tailwind-variants'; +import { cn } from '../lib/utils'; import { composeTailwindRenderProps } from '../utils'; import { Description, FieldError, Label } from './Field'; @@ -20,6 +21,7 @@ export interface RadioGroupProps extends Omit { children?: ReactNode; description?: string; errorMessage?: string | ((validation: ValidationResult) => string); + labelClassName?: string; } export const RadioGroup = (props: RadioGroupProps) => { @@ -31,7 +33,7 @@ export const RadioGroup = (props: RadioGroupProps) => { 'group flex flex-col gap-2', )} > -