+
{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 ? (
-
- ) : 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 ? (
+
+ ) : 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')}
{
onRetry={() => void refetch()}
roles={roles}
isMobile={isMobile}
+ invites={invites ?? []}
/>
void;
roles: { id: string; name: string }[];
isMobile: boolean;
+ invites: ProfileInvite[];
}) => {
const t = useTranslations();
@@ -57,7 +59,7 @@ export const ProfileUsersAccessTable = ({
);
}
- if (profileUsers.length === 0 && !isLoading) {
+ if (profileUsers.length === 0 && invites.length === 0 && !isLoading) {
return (
}>
{t('No members found')}
@@ -72,6 +74,7 @@ export const ProfileUsersAccessTable = ({
profileId={profileId}
isLoading={isLoading}
roles={roles}
+ invites={invites}
/>
);
}
@@ -84,6 +87,7 @@ export const ProfileUsersAccessTable = ({
onSortChange={onSortChange}
isLoading={isLoading}
roles={roles}
+ invites={invites}
/>
);
};
@@ -149,6 +153,61 @@ const ProfileUserRoleSelect = ({
);
};
+const InviteRoleSelect = ({
+ inviteId,
+ currentRoleId,
+ profileId,
+ roles,
+ className = 'sm:w-32',
+}: {
+ inviteId: string;
+ currentRoleId: string;
+ profileId: string;
+ roles: { id: string; name: string }[];
+ className?: string;
+}) => {
+ const t = useTranslations();
+ const utils = trpc.useUtils();
+
+ const updateInvite = trpc.profile.updateProfileInvite.useMutation({
+ onSuccess: () => {
+ toast.success({ message: t('Role updated successfully') });
+ void utils.profile.listProfileInvites.invalidate({ profileId });
+ },
+ onError: (error) => {
+ toast.error({
+ message: error.message || t('Failed to update role'),
+ });
+ },
+ });
+
+ const handleRoleChange = (roleId: string) => {
+ if (roleId && roleId !== currentRoleId) {
+ updateInvite.mutate({
+ inviteId,
+ accessRoleId: roleId,
+ });
+ }
+ };
+
+ return (
+ handleRoleChange(key as string)}
+ isDisabled={updateInvite.isPending}
+ size="small"
+ className={className}
+ >
+ {roles.map((role) => (
+
+ {role.name}
+
+ ))}
+
+ );
+};
+
const MobileProfileUserCard = ({
profileUser,
profileId,
@@ -203,29 +262,79 @@ const MobileProfileUserCard = ({
);
};
+const MobileInviteCard = ({
+ invite,
+ profileId,
+ roles,
+}: {
+ invite: ProfileInvite;
+ profileId: string;
+ roles: { id: string; name: string }[];
+}) => {
+ const t = useTranslations();
+ const displayName = invite.inviteeProfile?.name ?? invite.email;
+
+ return (
+
+
+
+
+
+ {displayName}
+ {t('Invited')}
+
+
+ {invite.email}
+
+
+
+
+
+ );
+};
+
const MobileProfileUsersContent = ({
profileUsers,
profileId,
isLoading,
roles,
+ invites,
}: {
profileUsers: ProfileUser[];
profileId: string;
isLoading: boolean;
roles: { id: string; name: string }[];
+ invites: ProfileInvite[];
}) => {
return (
{isLoading && }
- {!isLoading &&
- profileUsers.map((profileUser) => (
-
- ))}
+ {!isLoading && (
+ <>
+ {invites.map((invite) => (
+
+ ))}
+ {profileUsers.map((profileUser) => (
+
+ ))}
+ >
+ )}
);
};
@@ -238,6 +347,7 @@ const ProfileUsersAccessTableContent = ({
onSortChange,
isLoading,
roles,
+ invites,
}: {
profileUsers: ProfileUser[];
profileId: string;
@@ -245,6 +355,7 @@ const ProfileUsersAccessTableContent = ({
onSortChange: (descriptor: SortDescriptor) => void;
isLoading: boolean;
roles: { id: string; name: string }[];
+ invites: ProfileInvite[];
}) => {
const t = useTranslations();
@@ -256,7 +367,7 @@ const ProfileUsersAccessTableContent = ({
)}
+ {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',
)}
>
-
+
{props.label}
{props.isRequired && * }
diff --git a/packages/ui/stories/Button.stories.tsx b/packages/ui/stories/Button.stories.tsx
index 0cb538598..7de443333 100644
--- a/packages/ui/stories/Button.stories.tsx
+++ b/packages/ui/stories/Button.stories.tsx
@@ -15,9 +15,13 @@ export default {
unstyled: {
control: 'boolean',
},
+ isLoading: {
+ control: 'boolean',
+ },
},
args: {
isDisabled: false,
+ isLoading: false,
children: 'Button',
},
};
@@ -39,6 +43,20 @@ export const Example = () => (
Button
+ Loading:
+ Button
+
+ Button
+
+
+ Button
+
+
+ Button
+
+
+ Button
+
);
@@ -59,3 +77,16 @@ export const Destructive = {
color: 'destructive',
},
};
+
+export const Loading = {
+ args: {
+ isLoading: true,
+ },
+};
+
+export const LoadingSmall = {
+ args: {
+ isLoading: true,
+ size: 'small',
+ },
+};
diff --git a/packages/ui/stories/ButtonLink.stories.tsx b/packages/ui/stories/ButtonLink.stories.tsx
new file mode 100644
index 000000000..e6b673171
--- /dev/null
+++ b/packages/ui/stories/ButtonLink.stories.tsx
@@ -0,0 +1,98 @@
+import { ButtonLink } from '../src/components/Button';
+
+export default {
+ title: 'ButtonLink',
+ component: ButtonLink,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ color: {
+ control: 'select',
+ options: ['primary', 'secondary', 'gradient', 'destructive'],
+ },
+ isLoading: {
+ control: 'boolean',
+ },
+ },
+ args: {
+ isDisabled: false,
+ isLoading: false,
+ children: 'ButtonLink',
+ href: '#',
+ },
+};
+
+export const Example = () => (
+
+ Medium:
+ ButtonLink
+
+ ButtonLink
+
+
+ ButtonLink
+
+
+ ButtonLink
+
+ Small:
+
+ ButtonLink
+
+
+ ButtonLink
+
+
+ ButtonLink
+
+ Loading:
+
+ ButtonLink
+
+
+ ButtonLink
+
+
+ ButtonLink
+
+
+ ButtonLink
+
+
+ ButtonLink
+
+
+);
+
+export const Primary = {
+ args: {
+ color: 'primary',
+ },
+};
+
+export const Secondary = {
+ args: {
+ color: 'secondary',
+ },
+};
+
+export const Destructive = {
+ args: {
+ color: 'destructive',
+ },
+};
+
+export const Loading = {
+ args: {
+ isLoading: true,
+ },
+};
+
+export const LoadingSmall = {
+ args: {
+ isLoading: true,
+ size: 'small',
+ },
+};
diff --git a/services/api/src/encoders/decision.ts b/services/api/src/encoders/decision.ts
index 39f44697a..564f545e0 100644
--- a/services/api/src/encoders/decision.ts
+++ b/services/api/src/encoders/decision.ts
@@ -19,11 +19,79 @@ import { baseProfileEncoder } from './profiles';
// JSON Schema types
const jsonSchemaEncoder = z.record(z.string(), z.unknown());
+/**
+ * Typed encoder for rubric templates (READ path only).
+ *
+ * This provides strict typing so the frontend receives properly typed data
+ * from `instanceData.rubricTemplate` without needing type assertions.
+ *
+ * For the WRITE path (mutations), we use `jsonSchemaEncoder` instead because
+ * the frontend sends `RubricTemplateSchema` which extends `JSONSchema7` - a
+ * much broader type that doesn't fit this strict schema. Runtime validation
+ * still occurs via Zod; this encoder is primarily for TypeScript inference.
+ */
+const rubricTemplateEncoder = z
+ .object({
+ type: z.literal('object'),
+ properties: z
+ .record(
+ z.string(),
+ z
+ .object({
+ type: z.string().optional(),
+ title: z.string().optional(),
+ description: z.string().optional(),
+ minimum: z.number().optional(),
+ maximum: z.number().optional(),
+ oneOf: z
+ .array(
+ z.object({
+ const: z.union([z.number(), z.string()]),
+ title: z.string(),
+ }),
+ )
+ .optional(),
+ 'x-format': z.string().optional(),
+ })
+ .passthrough(),
+ )
+ .optional(),
+ required: z.array(z.string()).optional(),
+ 'x-field-order': z.array(z.string()).optional(),
+ })
+ .passthrough();
+
+// ============================================================================
+// ProcessPhase encoder (for frontend UI components)
+// ============================================================================
+
+/** Process phase encoder for UI display (stepper, stats, etc.) */
+export const processPhaseSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string().optional(),
+ phase: z
+ .object({
+ startDate: z.string().optional(),
+ endDate: z.string().optional(),
+ sortOrder: z.number().optional(),
+ })
+ .optional(),
+ type: z.enum(['initial', 'intermediate', 'final']).optional(),
+ config: z
+ .object({
+ allowProposals: z.boolean().optional(),
+ })
+ .optional(),
+});
+
+export type ProcessPhase = z.infer;
+
// ============================================================================
// DecisionSchemaDefinition format encoders
// ============================================================================
-/** Phase behavior rules */
+/** Phase behavior rules */
const phaseRulesEncoder = z.object({
proposals: z
.object({
@@ -130,7 +198,7 @@ export const decisionProcessWithSchemaEncoder = createSelectSchema(
createdBy: baseProfileEncoder.optional(),
});
-/** List encoder for decision processes with new schema format */
+/** List encoder for decision processes */
export const decisionProcessWithSchemaListEncoder = z.object({
processes: z.array(decisionProcessWithSchemaEncoder),
total: z.number(),
@@ -172,7 +240,7 @@ const instanceDataWithSchemaEncoder = z.object({
.optional(),
phases: z.array(instancePhaseDataEncoder).optional(),
proposalTemplate: jsonSchemaEncoder.optional(),
- rubricTemplate: jsonSchemaEncoder.optional(),
+ rubricTemplate: rubricTemplateEncoder.optional(),
});
/** Decision access permissions encoder */
@@ -243,90 +311,40 @@ export const decisionProfileWithSchemaFilterSchema = z.object({
stewardProfileId: z.uuid().optional(),
});
-// ============================================================================
-// Legacy format encoders (for backwards compatibility)
-// ============================================================================
-
-// Shared process phase schema
-export const processPhaseSchema = z.object({
- id: z.string(),
- name: z.string(),
- description: z.string().optional(),
- phase: z
- .object({
- startDate: z.string().optional(),
- endDate: z.string().optional(),
- sortOrder: z.number().optional(),
- })
- .optional(),
- type: z.enum(['initial', 'intermediate', 'final']).optional(),
-});
-
-// Process Schema Encoder
-const processSchemaEncoder = z.object({
- name: z.string(),
- description: z.string().optional(),
- budget: z.number().optional(),
- fields: jsonSchemaEncoder.optional(),
- states: z.array(
- processPhaseSchema.extend({
- fields: jsonSchemaEncoder.optional(),
- config: z
- .object({
- allowProposals: z.boolean().optional(),
- allowDecisions: z.boolean().optional(),
- visibleComponents: z.array(z.string()).optional(),
- })
- .optional(),
- }),
- ),
- transitions: z.array(
- z.object({
- id: z.string(),
- name: z.string(),
- from: z.union([z.string(), z.array(z.string())]),
- to: z.string(),
- rules: z
- .object({
- type: z.enum(['manual', 'automatic']),
- conditions: z
- .array(
- z.object({
- type: z.enum([
- 'time',
- 'proposalCount',
- 'participationCount',
- 'approvalRate',
- 'customField',
- ]),
- operator: z.enum([
- 'equals',
- 'greaterThan',
- 'lessThan',
- 'between',
- ]),
- value: z.unknown().optional(),
- field: z.string().optional(),
- }),
- )
- .optional(),
- requireAll: z.boolean().optional(),
- })
- .optional(),
- actions: z
- .array(
- z.object({
- type: z.enum(['notify', 'updateField', 'createRecord']),
- config: z.record(z.string(), z.unknown()),
- }),
- )
- .optional(),
- }),
- ),
- initialState: z.string(),
- decisionDefinition: jsonSchemaEncoder,
- proposalTemplate: jsonSchemaEncoder,
-});
+// =============================================================================
+// Process Schema Encoder (new format with passthrough for flexibility)
+// =============================================================================
+const processSchemaEncoder = z
+ .object({
+ name: z.string(),
+ description: z.string().optional(),
+ id: z.string().optional(),
+ version: z.string().optional(),
+ config: z
+ .object({
+ hideBudget: z.boolean().optional(),
+ })
+ .passthrough()
+ .optional(),
+ phases: z
+ .array(
+ z
+ .object({
+ id: z.string(),
+ name: z.string(),
+ description: z.string().optional(),
+ rules: phaseRulesEncoder.optional(),
+ selectionPipeline: selectionPipelineEncoder.optional(),
+ settings: jsonSchemaEncoder.optional(),
+ startDate: z.string().optional(),
+ endDate: z.string().optional(),
+ })
+ .passthrough(),
+ )
+ .optional(),
+ proposalTemplate: jsonSchemaEncoder.optional(),
+ })
+ .passthrough();
// Instance Data Encoder that supports both new and legacy field names
const instanceDataEncoder = z.preprocess(
@@ -444,7 +462,7 @@ export const documentContentEncoder = z.discriminatedUnion('type', [
fragments: z.record(
z.string(),
z.object({
- type: z.string(),
+ type: z.string().optional(),
content: z.array(z.unknown()).optional(),
}),
),
@@ -620,7 +638,12 @@ export const updateDecisionInstanceInputSchema = z.object({
phases: z.array(instancePhaseDataInputEncoder).optional(),
/** Proposal template (JSON Schema) */
proposalTemplate: jsonSchemaEncoder.optional(),
- /** Rubric template (JSON Schema defining evaluation criteria) */
+ /**
+ * Rubric template (JSON Schema defining evaluation criteria).
+ * Uses loose jsonSchemaEncoder for input because the frontend sends
+ * RubricTemplateSchema (extends JSONSchema7). See rubricTemplateEncoder
+ * for the typed read path.
+ */
rubricTemplate: jsonSchemaEncoder.optional(),
});
diff --git a/services/api/src/encoders/profiles.ts b/services/api/src/encoders/profiles.ts
index a73e82c46..70b91e2cd 100644
--- a/services/api/src/encoders/profiles.ts
+++ b/services/api/src/encoders/profiles.ts
@@ -99,17 +99,17 @@ export const profileUserEncoder = createSelectSchema(profileUsers).extend({
roles: z.array(accessRoleMinimalEncoder),
});
-// Profile invite encoder for pending invitations
-// accessRoleId is NOT NULL in schema, so role is always present
+// Profile invite encoder for pending invitations returned by listProfileInvites
export const profileInviteEncoder = createSelectSchema(profileInvites)
.pick({
id: true,
email: true,
- profileId: true,
+ accessRoleId: true,
createdAt: true,
})
.extend({
- role: accessRoleMinimalEncoder,
+ inviteeProfile: profileMinimalEncoder.nullable(),
});
export type ProfileUser = z.infer;
+export type ProfileInvite = z.infer;
diff --git a/services/api/src/middlewares/withAuthenticated.ts b/services/api/src/middlewares/withAuthenticated.ts
index 9e976a1e1..d1419f235 100644
--- a/services/api/src/middlewares/withAuthenticated.ts
+++ b/services/api/src/middlewares/withAuthenticated.ts
@@ -2,7 +2,7 @@ import { cache } from '@op/cache';
import { getAllowListUser } from '@op/common';
import { TRPCError } from '@trpc/server';
-import { createSBAdminClient } from '../supabase/server';
+import { getCachedAuthUser } from '../supabase/server';
import type { MiddlewareBuilderBase, TContextWithUser } from '../types';
import { verifyAuthentication } from '../utils/verifyAuthentication';
@@ -10,8 +10,7 @@ const withAuthenticated: MiddlewareBuilderBase = async ({
ctx,
next,
}) => {
- const supabase = createSBAdminClient(ctx);
- const data = await supabase.auth.getUser();
+ const data = await getCachedAuthUser(ctx);
const user = verifyAuthentication(data);
@@ -45,8 +44,7 @@ const withAuthenticated: MiddlewareBuilderBase = async ({
export const withAuthenticatedAdmin: MiddlewareBuilderBase<
TContextWithUser
> = async ({ ctx, next }) => {
- const supabase = createSBAdminClient(ctx);
- const data = await supabase.auth.getUser();
+ const data = await getCachedAuthUser(ctx);
const user = verifyAuthentication(data, true);
diff --git a/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts b/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts
index af9abeb15..075176714 100644
--- a/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts
+++ b/services/api/src/middlewares/withAuthenticatedPlatformAdmin.ts
@@ -1,6 +1,6 @@
import { UnauthorizedError, isUserEmailPlatformAdmin } from '@op/common';
-import { createSBAdminClient } from '../supabase/server';
+import { getCachedAuthUser } from '../supabase/server';
import type { MiddlewareBuilderBase, TContextWithUser } from '../types';
import { verifyAuthentication } from '../utils/verifyAuthentication';
@@ -10,8 +10,7 @@ import { verifyAuthentication } from '../utils/verifyAuthentication';
export const withAuthenticatedPlatformAdmin: MiddlewareBuilderBase<
TContextWithUser
> = async ({ ctx, next }) => {
- const supabase = createSBAdminClient(ctx);
- const data = await supabase.auth.getUser();
+ const data = await getCachedAuthUser(ctx);
const user = verifyAuthentication(data);
diff --git a/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts b/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts
index 2f9d5640d..ca3646196 100644
--- a/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts
+++ b/services/api/src/routers/decision/instances/updateDecisionInstance.test.ts
@@ -537,7 +537,9 @@ describe.concurrent('updateDecisionInstance', () => {
const result = await caller.decision.updateDecisionInstance({
instanceId: instance.instance.id,
- rubricTemplate: validRubricTemplate,
+ rubricTemplate: validRubricTemplate as typeof validRubricTemplate & {
+ type: 'object';
+ },
});
expect(result.processInstance.id).toBe(instance.instance.id);
diff --git a/services/api/src/routers/profile/index.ts b/services/api/src/routers/profile/index.ts
index 7d5b80e6f..f1614387e 100644
--- a/services/api/src/routers/profile/index.ts
+++ b/services/api/src/routers/profile/index.ts
@@ -19,6 +19,7 @@ import {
} from './requests';
import { searchProfilesRouter } from './searchProfiles';
import { updateDecisionRolesRouter } from './updateDecisionRoles';
+import { updateProfileInviteRouter } from './updateProfileInvite';
import { updateRolePermissionRouter } from './updateRolePermission';
import { usersRouter } from './users';
@@ -28,6 +29,7 @@ const profileRouter = mergeRouters(
getDecisionRoleRouter,
updateDecisionRolesRouter,
deleteProfileInviteRouter,
+ updateProfileInviteRouter,
getProfileRouter,
searchProfilesRouter,
profileRelationshipRouter,
diff --git a/services/api/src/routers/profile/listProfileInvites.test.ts b/services/api/src/routers/profile/listProfileInvites.test.ts
new file mode 100644
index 000000000..aef6bcd7f
--- /dev/null
+++ b/services/api/src/routers/profile/listProfileInvites.test.ts
@@ -0,0 +1,162 @@
+import { db } from '@op/db/client';
+import { EntityType, profileInvites } from '@op/db/schema';
+import { ROLES } from '@op/db/seedData/accessControl';
+import { describe, expect, it } from 'vitest';
+
+import { TestProfileUserDataManager } from '../../test/helpers/TestProfileUserDataManager';
+import {
+ createIsolatedSession,
+ createTestContextWithSession,
+} from '../../test/supabase-utils';
+import { createCallerFactory } from '../../trpcFactory';
+import profileRouter from './index';
+
+describe.concurrent('profile.listProfileInvites', () => {
+ const createCaller = createCallerFactory(profileRouter);
+
+ it('should return pending invites with accessRoleId', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestProfileUserDataManager(task.id, onTestFinished);
+ const { profile, adminUser } = await testData.createProfile({
+ users: { admin: 1 },
+ });
+
+ // Create a pending invite
+ const inviteeEmail = `invitee-${task.id}@oneproject.org`;
+ testData.trackProfileInvite(inviteeEmail, profile.id);
+
+ await db.insert(profileInvites).values({
+ email: inviteeEmail,
+ profileId: profile.id,
+ profileEntityType: EntityType.ORG,
+ accessRoleId: ROLES.MEMBER.id,
+ invitedBy: adminUser.userProfileId,
+ });
+
+ const { session } = await createIsolatedSession(adminUser.email);
+ const caller = createCaller(await createTestContextWithSession(session));
+
+ const result = await caller.listProfileInvites({
+ profileId: profile.id,
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ email: inviteeEmail,
+ accessRoleId: ROLES.MEMBER.id,
+ });
+ });
+
+ it('should return correct accessRoleId for different roles', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestProfileUserDataManager(task.id, onTestFinished);
+ const { profile, adminUser } = await testData.createProfile({
+ users: { admin: 1 },
+ });
+
+ // Create invites with different roles
+ const memberEmail = `member-invite-${task.id}@oneproject.org`;
+ const adminEmail = `admin-invite-${task.id}@oneproject.org`;
+ testData.trackProfileInvite(memberEmail, profile.id);
+ testData.trackProfileInvite(adminEmail, profile.id);
+
+ await db.insert(profileInvites).values([
+ {
+ email: memberEmail,
+ profileId: profile.id,
+ profileEntityType: EntityType.ORG,
+ accessRoleId: ROLES.MEMBER.id,
+ invitedBy: adminUser.userProfileId,
+ },
+ {
+ email: adminEmail,
+ profileId: profile.id,
+ profileEntityType: EntityType.ORG,
+ accessRoleId: ROLES.ADMIN.id,
+ invitedBy: adminUser.userProfileId,
+ },
+ ]);
+
+ const { session } = await createIsolatedSession(adminUser.email);
+ const caller = createCaller(await createTestContextWithSession(session));
+
+ const result = await caller.listProfileInvites({
+ profileId: profile.id,
+ });
+
+ expect(result).toHaveLength(2);
+
+ const memberInvite = result.find((r) => r.email === memberEmail);
+ const adminInvite = result.find((r) => r.email === adminEmail);
+
+ expect(memberInvite?.accessRoleId).toBe(ROLES.MEMBER.id);
+ expect(adminInvite?.accessRoleId).toBe(ROLES.ADMIN.id);
+ });
+
+ it('should only return pending invites (not accepted ones)', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestProfileUserDataManager(task.id, onTestFinished);
+ const { profile, adminUser } = await testData.createProfile({
+ users: { admin: 1 },
+ });
+
+ const pendingEmail = `pending-${task.id}@oneproject.org`;
+ const acceptedEmail = `accepted-${task.id}@oneproject.org`;
+ testData.trackProfileInvite(pendingEmail, profile.id);
+ testData.trackProfileInvite(acceptedEmail, profile.id);
+
+ await db.insert(profileInvites).values([
+ {
+ email: pendingEmail,
+ profileId: profile.id,
+ profileEntityType: EntityType.ORG,
+ accessRoleId: ROLES.MEMBER.id,
+ invitedBy: adminUser.userProfileId,
+ },
+ {
+ email: acceptedEmail,
+ profileId: profile.id,
+ profileEntityType: EntityType.ORG,
+ accessRoleId: ROLES.MEMBER.id,
+ invitedBy: adminUser.userProfileId,
+ acceptedOn: new Date().toISOString(),
+ },
+ ]);
+
+ const { session } = await createIsolatedSession(adminUser.email);
+ const caller = createCaller(await createTestContextWithSession(session));
+
+ const result = await caller.listProfileInvites({
+ profileId: profile.id,
+ });
+
+ expect(result).toHaveLength(1);
+ expect(result[0]?.email).toBe(pendingEmail);
+ expect(result[0]?.accessRoleId).toBe(ROLES.MEMBER.id);
+ });
+
+ it('should return empty array when no pending invites exist', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestProfileUserDataManager(task.id, onTestFinished);
+ const { profile, adminUser } = await testData.createProfile({
+ users: { admin: 1 },
+ });
+
+ const { session } = await createIsolatedSession(adminUser.email);
+ const caller = createCaller(await createTestContextWithSession(session));
+
+ const result = await caller.listProfileInvites({
+ profileId: profile.id,
+ });
+
+ expect(result).toHaveLength(0);
+ });
+});
diff --git a/services/api/src/routers/profile/listProfileInvites.ts b/services/api/src/routers/profile/listProfileInvites.ts
index e34948c36..4f7de0fb0 100644
--- a/services/api/src/routers/profile/listProfileInvites.ts
+++ b/services/api/src/routers/profile/listProfileInvites.ts
@@ -1,7 +1,7 @@
import { listProfileUserInvites } from '@op/common';
import { z } from 'zod';
-import { profileMinimalEncoder } from '../../encoders/profiles';
+import { profileInviteEncoder } from '../../encoders/profiles';
import { commonAuthedProcedure, router } from '../../trpcFactory';
const inputSchema = z.object({
@@ -9,14 +9,7 @@ const inputSchema = z.object({
query: z.string().min(2).optional(),
});
-const outputSchema = z.array(
- z.object({
- id: z.string(),
- email: z.string(),
- createdAt: z.string().nullable(),
- inviteeProfile: profileMinimalEncoder.nullable(),
- }),
-);
+const outputSchema = z.array(profileInviteEncoder);
export const listProfileInvitesRouter = router({
listProfileInvites: commonAuthedProcedure()
@@ -32,6 +25,7 @@ export const listProfileInvitesRouter = router({
return invites.map((invite) => ({
id: invite.id,
email: invite.email,
+ accessRoleId: invite.accessRoleId,
createdAt: invite.createdAt,
inviteeProfile: invite.inviteeProfile ?? null,
}));
diff --git a/services/api/src/routers/profile/updateProfileInvite.ts b/services/api/src/routers/profile/updateProfileInvite.ts
new file mode 100644
index 000000000..599f7bbd9
--- /dev/null
+++ b/services/api/src/routers/profile/updateProfileInvite.ts
@@ -0,0 +1,31 @@
+import { updateProfileInvite } from '@op/common';
+import { z } from 'zod';
+
+import { profileInviteEncoder } from '../../encoders/profiles';
+import { commonAuthedProcedure, router } from '../../trpcFactory';
+
+export const updateProfileInviteRouter = router({
+ updateProfileInvite: commonAuthedProcedure()
+ .input(
+ z.object({
+ inviteId: z.string().uuid(),
+ accessRoleId: z.string().uuid(),
+ }),
+ )
+ .output(profileInviteEncoder)
+ .mutation(async ({ ctx, input }) => {
+ const invite = await updateProfileInvite({
+ inviteId: input.inviteId,
+ accessRoleId: input.accessRoleId,
+ user: ctx.user,
+ });
+
+ return {
+ id: invite.id,
+ email: invite.email,
+ accessRoleId: invite.accessRoleId,
+ createdAt: invite.createdAt,
+ inviteeProfile: invite.inviteeProfile,
+ };
+ }),
+});
diff --git a/services/api/src/routers/translation/index.ts b/services/api/src/routers/translation/index.ts
index 5ffa80bb7..d1c24657c 100644
--- a/services/api/src/routers/translation/index.ts
+++ b/services/api/src/routers/translation/index.ts
@@ -1,4 +1,8 @@
import { mergeRouters } from '../../trpcFactory';
import { translateProposalRouter } from './translateProposal';
+import { translateProposalsRouter } from './translateProposals';
-export const translationRouter = mergeRouters(translateProposalRouter);
+export const translationRouter = mergeRouters(
+ translateProposalRouter,
+ translateProposalsRouter,
+);
diff --git a/services/api/src/routers/translation/translateProposal.test.ts b/services/api/src/routers/translation/translateProposal.test.ts
index 6f6271c98..d5b5422af 100644
--- a/services/api/src/routers/translation/translateProposal.test.ts
+++ b/services/api/src/routers/translation/translateProposal.test.ts
@@ -341,4 +341,193 @@ describe.concurrent('translation.translateProposal', () => {
expect.objectContaining({ tagHandling: 'html' }),
);
});
+
+ it('should translate template field titles, descriptions, and dropdown options', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestDecisionsDataManager(task.id, onTestFinished);
+
+ const proposalTemplate = {
+ type: 'object',
+ properties: {
+ category: {
+ type: 'string',
+ title: 'Project Category',
+ description: 'Select the category for your proposal',
+ 'x-format': 'select',
+ oneOf: [
+ { const: 'infrastructure', title: 'Infrastructure' },
+ { const: 'education', title: 'Education' },
+ { const: 'health', title: 'Health Services' },
+ ],
+ },
+ summary: {
+ type: 'string',
+ title: 'Executive Summary',
+ description: 'Brief overview of your proposal',
+ 'x-format': 'textarea',
+ },
+ },
+ };
+
+ const setup = await testData.createDecisionSetup({
+ instanceCount: 1,
+ grantAccess: true,
+ proposalTemplate,
+ });
+
+ const instance = setup.instances[0];
+ if (!instance) {
+ throw new Error('No instance created');
+ }
+
+ const proposal = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Template Fields Test' },
+ });
+
+ const { collaborationDocId } = proposal.proposalData as {
+ collaborationDocId: string;
+ };
+ mockCollab.setDocResponse(collaborationDocId, {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Body content' }],
+ },
+ ],
+ });
+
+ const proposalId = proposal.id;
+ onTestFinished(async () => {
+ await db
+ .delete(contentTranslations)
+ .where(
+ like(contentTranslations.contentKey, `proposal:${proposalId}:%`),
+ );
+ });
+
+ const caller = await createAuthenticatedCaller(setup.userEmail);
+
+ const result = await caller.translation.translateProposal({
+ profileId: proposal.profileId,
+ targetLocale: 'es',
+ });
+
+ // Verify template field titles are translated
+ expect(result.translated['field_title:category']).toBe(
+ '[ES] Project Category',
+ );
+ expect(result.translated['field_title:summary']).toBe(
+ '[ES] Executive Summary',
+ );
+
+ // Verify template field descriptions are translated
+ expect(result.translated['field_desc:category']).toBe(
+ '[ES] Select the category for your proposal',
+ );
+ expect(result.translated['field_desc:summary']).toBe(
+ '[ES] Brief overview of your proposal',
+ );
+
+ // Verify dropdown option labels are translated
+ expect(result.translated['option:category:infrastructure']).toBe(
+ '[ES] Infrastructure',
+ );
+ expect(result.translated['option:category:education']).toBe(
+ '[ES] Education',
+ );
+ expect(result.translated['option:category:health']).toBe(
+ '[ES] Health Services',
+ );
+
+ // Verify standard fields are still present
+ expect(result.translated['title']).toBe('[ES] Template Fields Test');
+ expect(result.sourceLocale).toBe('EN');
+ expect(result.targetLocale).toBe('es');
+ });
+
+ it('should use cached template field translations without calling DeepL', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestDecisionsDataManager(task.id, onTestFinished);
+ const translationData = new TestTranslationDataManager(onTestFinished);
+
+ const proposalTemplate = {
+ type: 'object',
+ properties: {
+ region: {
+ type: 'string',
+ title: 'Target Region',
+ 'x-format': 'text',
+ },
+ },
+ };
+
+ const setup = await testData.createDecisionSetup({
+ instanceCount: 1,
+ grantAccess: true,
+ proposalTemplate,
+ });
+
+ const instance = setup.instances[0];
+ if (!instance) {
+ throw new Error('No instance created');
+ }
+
+ const proposal = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Cached Template Test' },
+ });
+
+ const { collaborationDocId } = proposal.proposalData as {
+ collaborationDocId: string;
+ };
+ mockCollab.setDocResponse(collaborationDocId, {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Some body' }],
+ },
+ ],
+ });
+
+ // Pre-seed the field title translation in the cache
+ await translationData.seedTranslation({
+ contentKey: `proposal:${proposal.id}:field_title:region`,
+ sourceText: 'Target Region',
+ translatedText: '[ES-CACHED] Target Region',
+ sourceLocale: 'EN',
+ targetLocale: 'ES',
+ });
+
+ onTestFinished(async () => {
+ await db
+ .delete(contentTranslations)
+ .where(
+ like(contentTranslations.contentKey, `proposal:${proposal.id}:%`),
+ );
+ });
+
+ const caller = await createAuthenticatedCaller(setup.userEmail);
+
+ const result = await caller.translation.translateProposal({
+ profileId: proposal.profileId,
+ targetLocale: 'es',
+ });
+
+ // Field title should come from cache
+ expect(result.translated['field_title:region']).toBe(
+ '[ES-CACHED] Target Region',
+ );
+
+ // Title and body should go through DeepL mock
+ expect(result.translated['title']).toBe('[ES] Cached Template Test');
+ });
});
diff --git a/services/api/src/routers/translation/translateProposals.test.ts b/services/api/src/routers/translation/translateProposals.test.ts
new file mode 100644
index 000000000..be5c3df01
--- /dev/null
+++ b/services/api/src/routers/translation/translateProposals.test.ts
@@ -0,0 +1,269 @@
+import { mockCollab } from '@op/collab/testing';
+import { db } from '@op/db/client';
+import { contentTranslations } from '@op/db/schema';
+import { like } from 'drizzle-orm';
+import { describe, expect, it, vi } from 'vitest';
+
+import { appRouter } from '..';
+import { TestDecisionsDataManager } from '../../test/helpers/TestDecisionsDataManager';
+import { TestTranslationDataManager } from '../../test/helpers/TestTranslationDataManager';
+import {
+ createIsolatedSession,
+ createTestContextWithSession,
+} from '../../test/supabase-utils';
+import { createCallerFactory } from '../../trpcFactory';
+
+// Set a fake API key so the endpoint doesn't throw before reaching the mock
+process.env.DEEPL_API_KEY = 'test-fake-key';
+
+// Mock DeepL's translateText — prefixes each text with [ES] so we can
+// distinguish mock translations from seeded cache entries ([ES-CACHED]).
+const mockTranslateText = vi.fn((texts: string[]) =>
+ texts.map((t) => ({
+ text: `[ES] ${t}`,
+ detectedSourceLang: 'en',
+ })),
+);
+
+// Mock deepl-node so we never hit the real API
+vi.mock('deepl-node', () => ({
+ DeepLClient: class {
+ translateText = mockTranslateText;
+ },
+}));
+
+const createCaller = createCallerFactory(appRouter);
+
+async function createAuthenticatedCaller(email: string) {
+ const { session } = await createIsolatedSession(email);
+ return createCaller(await createTestContextWithSession(session));
+}
+
+describe.concurrent('translation.translateProposals', () => {
+ it('should translate title and preview for multiple proposals', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestDecisionsDataManager(task.id, onTestFinished);
+
+ const setup = await testData.createDecisionSetup({
+ instanceCount: 1,
+ grantAccess: true,
+ });
+
+ const instance = setup.instances[0];
+ if (!instance) {
+ throw new Error('No instance created');
+ }
+
+ // Create two proposals with different content
+ const proposal1 = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Solar Panel Initiative' },
+ });
+
+ const proposal2 = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Water Purification Project' },
+ });
+
+ // Set up mock TipTap documents for both proposals
+ const { collaborationDocId: docId1 } = proposal1.proposalData as {
+ collaborationDocId: string;
+ };
+ mockCollab.setDocResponse(docId1, {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: 'Installing solar panels in rural areas' },
+ ],
+ },
+ ],
+ });
+
+ const { collaborationDocId: docId2 } = proposal2.proposalData as {
+ collaborationDocId: string;
+ };
+ mockCollab.setDocResponse(docId2, {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [
+ { type: 'text', text: 'Clean water for remote communities' },
+ ],
+ },
+ ],
+ });
+
+ // Clean up translations for both proposals
+ onTestFinished(async () => {
+ await db
+ .delete(contentTranslations)
+ .where(
+ like(
+ contentTranslations.contentKey,
+ `batch:${proposal1.profileId}:%`,
+ ),
+ );
+ await db
+ .delete(contentTranslations)
+ .where(
+ like(
+ contentTranslations.contentKey,
+ `batch:${proposal2.profileId}:%`,
+ ),
+ );
+ });
+
+ const caller = await createAuthenticatedCaller(setup.userEmail);
+
+ const result = await caller.translation.translateProposals({
+ profileIds: [proposal1.profileId, proposal2.profileId],
+ targetLocale: 'es',
+ });
+
+ expect(result.targetLocale).toBe('es');
+ expect(result.sourceLocale).toBe('EN');
+
+ // Verify proposal 1 translations are grouped by profileId
+ const t1 = result.translations[proposal1.profileId];
+ expect(t1).toBeDefined();
+ expect(t1?.title).toBe('[ES] Solar Panel Initiative');
+ expect(t1?.preview).toMatch(/^\[ES\] .*solar panels/i);
+
+ // Verify proposal 2 translations are grouped by profileId
+ const t2 = result.translations[proposal2.profileId];
+ expect(t2).toBeDefined();
+ expect(t2?.title).toBe('[ES] Water Purification Project');
+ expect(t2?.preview).toMatch(/^\[ES\] .*clean water/i);
+ });
+
+ it('should return cached batch translations without calling DeepL', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestDecisionsDataManager(task.id, onTestFinished);
+ const translationData = new TestTranslationDataManager(onTestFinished);
+
+ const setup = await testData.createDecisionSetup({
+ instanceCount: 1,
+ grantAccess: true,
+ });
+
+ const instance = setup.instances[0];
+ if (!instance) {
+ throw new Error('No instance created');
+ }
+
+ const proposal = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Cached Batch Proposal' },
+ });
+
+ const { collaborationDocId } = proposal.proposalData as {
+ collaborationDocId: string;
+ };
+ mockCollab.setDocResponse(collaborationDocId, {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Some preview content' }],
+ },
+ ],
+ });
+
+ // Pre-seed the title translation in the batch cache
+ await translationData.seedTranslation({
+ contentKey: `batch:${proposal.profileId}:title`,
+ sourceText: 'Cached Batch Proposal',
+ translatedText: '[ES-CACHED] Cached Batch Proposal',
+ sourceLocale: 'EN',
+ targetLocale: 'ES',
+ });
+
+ onTestFinished(async () => {
+ await db
+ .delete(contentTranslations)
+ .where(
+ like(contentTranslations.contentKey, `batch:${proposal.profileId}:%`),
+ );
+ });
+
+ const caller = await createAuthenticatedCaller(setup.userEmail);
+
+ const result = await caller.translation.translateProposals({
+ profileIds: [proposal.profileId],
+ targetLocale: 'es',
+ });
+
+ const t = result.translations[proposal.profileId];
+ expect(t).toBeDefined();
+
+ // Title should come from cache
+ expect(t?.title).toBe('[ES-CACHED] Cached Batch Proposal');
+
+ // Preview should go through DeepL mock
+ expect(t?.preview).toMatch(/^\[ES\] /);
+ });
+
+ it('should translate title but omit preview when document is empty', async ({
+ task,
+ onTestFinished,
+ }) => {
+ const testData = new TestDecisionsDataManager(task.id, onTestFinished);
+
+ const setup = await testData.createDecisionSetup({
+ instanceCount: 1,
+ grantAccess: true,
+ });
+
+ const instance = setup.instances[0];
+ if (!instance) {
+ throw new Error('No instance created');
+ }
+
+ // Create a proposal with a title but an empty TipTap document
+ const proposal = await testData.createProposal({
+ callerEmail: setup.userEmail,
+ processInstanceId: instance.instance.id,
+ proposalData: { title: 'Minimal Proposal' },
+ });
+
+ // Set up an empty TipTap document (no text content → no preview)
+ const { collaborationDocId } = proposal.proposalData as {
+ collaborationDocId: string;
+ };
+ mockCollab.setDocResponse(collaborationDocId, {
+ type: 'doc',
+ content: [],
+ });
+
+ onTestFinished(async () => {
+ await db
+ .delete(contentTranslations)
+ .where(
+ like(contentTranslations.contentKey, `batch:${proposal.profileId}:%`),
+ );
+ });
+
+ const caller = await createAuthenticatedCaller(setup.userEmail);
+
+ const result = await caller.translation.translateProposals({
+ profileIds: [proposal.profileId],
+ targetLocale: 'es',
+ });
+
+ // Should still translate the title even with no preview
+ const t = result.translations[proposal.profileId];
+ expect(t).toBeDefined();
+ expect(t?.title).toBe('[ES] Minimal Proposal');
+ expect(t?.preview).toBeUndefined();
+ });
+});
diff --git a/services/api/src/routers/translation/translateProposals.ts b/services/api/src/routers/translation/translateProposals.ts
new file mode 100644
index 000000000..17e9459ff
--- /dev/null
+++ b/services/api/src/routers/translation/translateProposals.ts
@@ -0,0 +1,35 @@
+import { SUPPORTED_LOCALES, translateProposals } from '@op/common';
+import { z } from 'zod';
+
+import { commonAuthedProcedure, router } from '../../trpcFactory';
+
+export const translateProposalsRouter = router({
+ translateProposals: commonAuthedProcedure()
+ .input(
+ z.object({
+ profileIds: z.array(z.uuid()).min(1).max(100),
+ targetLocale: z.enum(SUPPORTED_LOCALES),
+ }),
+ )
+ .output(
+ z.object({
+ translations: z.record(
+ z.string(),
+ z.object({
+ title: z.string().optional(),
+ category: z.string().optional(),
+ preview: z.string().optional(),
+ }),
+ ),
+ sourceLocale: z.string(),
+ targetLocale: z.enum(SUPPORTED_LOCALES),
+ }),
+ )
+ .mutation(async ({ input, ctx }) => {
+ return translateProposals({
+ profileIds: input.profileIds,
+ targetLocale: input.targetLocale,
+ user: ctx.user,
+ });
+ }),
+});
diff --git a/services/api/src/supabase/server.ts b/services/api/src/supabase/server.ts
index bd277f180..3127300b4 100644
--- a/services/api/src/supabase/server.ts
+++ b/services/api/src/supabase/server.ts
@@ -3,7 +3,7 @@
import { OPURLConfig, cookieOptionsDomain } from '@op/core';
import { logger } from '@op/logging';
import { createServerClient } from '@op/supabase/lib';
-import type { CookieOptions } from '@op/supabase/lib';
+import type { CookieOptions, UserResponse } from '@op/supabase/lib';
import type { Database } from '@op/supabase/types';
import 'server-only';
@@ -11,6 +11,18 @@ import type { TContext } from '../types';
const useUrl = OPURLConfig('APP');
+const authUserCache = new WeakMap>();
+
+export function getCachedAuthUser(ctx: TContext): Promise {
+ let promise = authUserCache.get(ctx);
+ if (!promise) {
+ const supabase = createSBAdminClient(ctx);
+ promise = supabase.auth.getUser();
+ authUserCache.set(ctx, promise);
+ }
+ return promise;
+}
+
export const createSBAdminClient = (ctx: TContext) => {
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
diff --git a/tests/e2e/tests/create-process-instance.spec.ts b/tests/e2e/tests/create-process-instance.spec.ts
index eac8b0533..581edce80 100644
--- a/tests/e2e/tests/create-process-instance.spec.ts
+++ b/tests/e2e/tests/create-process-instance.spec.ts
@@ -3,17 +3,28 @@ import { expect, test } from '../fixtures/index.js';
// Use a wider viewport so the participant preview panel (xl:block >= 1280px) is visible.
test.use({ viewport: { width: 1440, height: 900 } });
+/** Resolves when the next updateDecisionInstance mutation succeeds. */
+function waitForAutoSave(page: import('@playwright/test').Page) {
+ return page.waitForResponse(
+ (resp) =>
+ resp.url().includes('decision.updateDecisionInstance') && resp.ok(),
+ { timeout: 12_000 },
+ );
+}
+
test.describe('Create Process Instance', () => {
test('can create a decision process and reach launch-ready state', async ({
authenticatedPage,
}) => {
+ test.setTimeout(144_000);
+
// 1. Navigate to the decisions create page (server-side creates a draft
// instance from the seeded template and redirects to the editor)
await authenticatedPage.goto('/en/decisions/create');
// 2. Wait for the process builder editor to load
await expect(authenticatedPage.getByText('Process Overview')).toBeVisible({
- timeout: 30000,
+ timeout: 36_000,
});
// ── Step 1: General – Overview ──────────────────────────────────────
@@ -27,36 +38,32 @@ test.describe('Create Process Instance', () => {
.getByRole('listbox')
.getByRole('option')
.first();
- await expect(stewardOption).toBeVisible({ timeout: 5000 });
+ await expect(stewardOption).toBeVisible({ timeout: 6_000 });
await stewardOption.click();
// 4. Fill in the process name
await authenticatedPage.getByLabel('Process Name').fill('E2E Test Process');
- // 5. Fill in the description
+ // 5. Fill in the description — start listening for the auto-save response
+ // before the final input so we don't miss the debounced mutation
+ const overviewSaved = waitForAutoSave(authenticatedPage);
await authenticatedPage
.getByLabel('Description')
.fill('A process created by E2E tests');
-
- // Wait for the debounced auto-save (1000ms) to flush to the API.
- // For draft instances, the store initializer always prefers server data,
- // so we reload after each section to get fresh server state.
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await authenticatedPage.waitForTimeout(2000);
- await authenticatedPage.reload({ waitUntil: 'networkidle' });
+ await overviewSaved;
// ── Step 1: General – Phases ────────────────────────────────────────
// 6. Navigate to the Phases section
const phasesTab = authenticatedPage.getByRole('tab', { name: 'Phases' });
- await expect(phasesTab).toBeVisible({ timeout: 15000 });
+ await expect(phasesTab).toBeVisible({ timeout: 18_000 });
await phasesTab.click();
await expect(
authenticatedPage.getByText(
'Define the phases of your decision-making process',
),
- ).toBeVisible({ timeout: 10000 });
+ ).toBeVisible({ timeout: 12_000 });
// 7. Fill each phase's required fields.
// The seeded template has phases rendered as collapsed accordions.
@@ -75,7 +82,7 @@ test.describe('Create Process Instance', () => {
// Wait for the phase content to be visible
const headlineField = phase.getByLabel('Headline');
- await expect(headlineField).toBeVisible({ timeout: 5000 });
+ await expect(headlineField).toBeVisible({ timeout: 6_000 });
// Fill phase name
await phase.getByLabel('Phase name').fill(`Phase ${i + 1}`);
@@ -101,10 +108,9 @@ test.describe('Create Process Instance', () => {
await endDateInput.press('Enter');
}
- // Wait for phases auto-save to flush, then reload
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await authenticatedPage.waitForTimeout(2000);
- await authenticatedPage.reload({ waitUntil: 'networkidle' });
+ // Wait for the phases auto-save to complete
+ const phasesSaved = waitForAutoSave(authenticatedPage);
+ await phasesSaved;
// ── Step 1: General – Proposal Categories ───────────────────────────
@@ -112,12 +118,12 @@ test.describe('Create Process Instance', () => {
const categoriesTab = authenticatedPage.getByRole('tab', {
name: 'Proposal Categories',
});
- await expect(categoriesTab).toBeVisible({ timeout: 15000 });
+ await expect(categoriesTab).toBeVisible({ timeout: 18_000 });
await categoriesTab.click();
await expect(
authenticatedPage.getByText('Proposal Categories').first(),
- ).toBeVisible({ timeout: 10000 });
+ ).toBeVisible({ timeout: 12_000 });
// 9. Create one category
await authenticatedPage
@@ -129,6 +135,8 @@ test.describe('Create Process Instance', () => {
.getByLabel('Full description')
.fill('Expand access to quality education in underserved communities');
+ // Start listening before the action that triggers auto-save
+ const categorySaved = waitForAutoSave(authenticatedPage);
await authenticatedPage
.getByRole('button', { name: 'Add category' })
.click();
@@ -138,9 +146,7 @@ test.describe('Create Process Instance', () => {
authenticatedPage.getByText('Education', { exact: true }),
).toBeVisible();
- // Wait for category auto-save to flush
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await authenticatedPage.waitForTimeout(1500);
+ await categorySaved;
// ── Step 2: Proposal Template ───────────────────────────────────────
@@ -152,41 +158,38 @@ test.describe('Create Process Instance', () => {
await expect(
authenticatedPage.getByText('Proposal template').first(),
- ).toBeVisible({ timeout: 10000 });
+ ).toBeVisible({ timeout: 12_000 });
// 11. Enable the Budget field in the template
+ const templateSaved = waitForAutoSave(authenticatedPage);
await authenticatedPage
.getByRole('button', { name: 'Show in template?' })
.click();
// Verify the budget config expanded (Currency select should appear)
await expect(authenticatedPage.getByLabel('Currency')).toBeVisible({
- timeout: 5000,
+ timeout: 6_000,
});
// 12. Verify the participant preview shows the budget field
// The preview renders an "Add budget" button when budget is enabled
await expect(
authenticatedPage.getByText('Participant Preview'),
- ).toBeVisible({ timeout: 5000 });
+ ).toBeVisible({ timeout: 6_000 });
await expect(
authenticatedPage.getByRole('button', { name: 'Add budget' }),
- ).toBeVisible({ timeout: 5000 });
+ ).toBeVisible({ timeout: 6_000 });
- // ── Final: Verify Launch Process button is enabled ──────────────────
+ await templateSaved;
- // 13. Wait for template auto-save to flush, then reload so the store
- // initializer picks up all saved data from the server
- // eslint-disable-next-line playwright/no-wait-for-timeout
- await authenticatedPage.waitForTimeout(2000);
- await authenticatedPage.reload({ waitUntil: 'networkidle' });
+ // ── Final: Verify Launch Process button is enabled ──────────────────
- // 14. Verify the Launch Process button is enabled (not disabled)
+ // 13. Verify the Launch Process button is enabled (not disabled)
const launchButton = authenticatedPage.getByRole('button', {
name: 'Launch Process',
});
- await expect(launchButton).toBeVisible({ timeout: 15000 });
- await expect(launchButton).toBeEnabled({ timeout: 15000 });
+ await expect(launchButton).toBeVisible({ timeout: 18_000 });
+ await expect(launchButton).toBeEnabled({ timeout: 18_000 });
});
});
diff --git a/tests/e2e/tests/proposal-submit-validation.spec.ts b/tests/e2e/tests/proposal-submit-validation.spec.ts
index 381fc8f5a..2cdf2ec67 100644
--- a/tests/e2e/tests/proposal-submit-validation.spec.ts
+++ b/tests/e2e/tests/proposal-submit-validation.spec.ts
@@ -122,7 +122,7 @@ test.describe('Proposal Submit Validation', () => {
status: ProposalStatus.DRAFT,
});
- await new Promise((resolve) => setTimeout(resolve, 500));
+ await new Promise((resolve) => setTimeout(resolve, 600));
// -- Navigate to editor ---------------------------------------------------
@@ -134,7 +134,7 @@ test.describe('Proposal Submit Validation', () => {
const submitButton = authenticatedPage.getByRole('button', {
name: 'Submit Proposal',
});
- await expect(submitButton).toBeVisible({ timeout: 30_000 });
+ await expect(submitButton).toBeVisible({ timeout: 36_000 });
// Helper: dismiss any visible toasts before the next submit
const dismissToasts = async () => {
@@ -147,7 +147,7 @@ test.describe('Proposal Submit Validation', () => {
}
}
// Wait for toast to animate out
- await authenticatedPage.waitForTimeout(300);
+ await authenticatedPage.waitForTimeout(360);
};
// =========================================================================
@@ -161,7 +161,7 @@ test.describe('Proposal Submit Validation', () => {
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' });
- await expect(errorToast).toBeVisible({ timeout: 5_000 });
+ await expect(errorToast).toBeVisible({ timeout: 6_000 });
await expect(errorToast).toContainText('Title');
await expect(errorToast).toContainText('Budget');
await expect(errorToast).toContainText('Category');
@@ -177,7 +177,7 @@ test.describe('Proposal Submit Validation', () => {
await authenticatedPage.keyboard.type('My Test Proposal');
// Wait for debounced auto-save to fire
- await authenticatedPage.waitForTimeout(2_000);
+ await authenticatedPage.waitForTimeout(2_400);
await submitButton.click();
@@ -185,7 +185,7 @@ test.describe('Proposal Submit Validation', () => {
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' });
- await expect(errorToast2).toBeVisible({ timeout: 5_000 });
+ await expect(errorToast2).toBeVisible({ timeout: 6_000 });
await expect(errorToast2).not.toContainText('Title');
await expect(errorToast2).toContainText('Budget');
await expect(errorToast2).toContainText('Category');
@@ -202,11 +202,11 @@ test.describe('Proposal Submit Validation', () => {
await addBudgetButton.click();
const budgetInput = authenticatedPage.getByPlaceholder('Enter amount');
- await expect(budgetInput).toBeVisible({ timeout: 5_000 });
+ await expect(budgetInput).toBeVisible({ timeout: 6_000 });
await budgetInput.fill('5000');
await budgetInput.blur();
- await authenticatedPage.waitForTimeout(2_000);
+ await authenticatedPage.waitForTimeout(2_400);
await submitButton.click();
@@ -214,7 +214,7 @@ test.describe('Proposal Submit Validation', () => {
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' });
- await expect(errorToast3).toBeVisible({ timeout: 5_000 });
+ await expect(errorToast3).toBeVisible({ timeout: 6_000 });
await expect(errorToast3).not.toContainText('Title');
await expect(errorToast3).not.toContainText('Budget');
await expect(errorToast3).toContainText('Category');
@@ -240,7 +240,7 @@ test.describe('Proposal Submit Validation', () => {
.getByRole('option', { name: 'Infrastructure' })
.click();
- await authenticatedPage.waitForTimeout(2_000);
+ await authenticatedPage.waitForTimeout(2_400);
await submitButton.click();
@@ -251,7 +251,7 @@ test.describe('Proposal Submit Validation', () => {
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' });
- await expect(errorToast4).toBeVisible({ timeout: 5_000 });
+ await expect(errorToast4).toBeVisible({ timeout: 6_000 });
await expect(errorToast4).toContainText('Summary');
await expect(errorToast4).toContainText('Details');
await expect(errorToast4).toContainText('Priority Level');
@@ -275,7 +275,7 @@ test.describe('Proposal Submit Validation', () => {
await summaryEditor.click();
await authenticatedPage.keyboard.type('A brief summary of the proposal');
- await authenticatedPage.waitForTimeout(2_000);
+ await authenticatedPage.waitForTimeout(2_400);
await submitButton.click();
@@ -283,7 +283,7 @@ test.describe('Proposal Submit Validation', () => {
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' });
- await expect(errorToast5).toBeVisible({ timeout: 5_000 });
+ await expect(errorToast5).toBeVisible({ timeout: 6_000 });
await expect(errorToast5).not.toContainText('Summary');
await expect(errorToast5).toContainText('Details');
await expect(errorToast5).toContainText('Priority Level');
@@ -307,7 +307,7 @@ test.describe('Proposal Submit Validation', () => {
'Full details and justification for this proposal',
);
- await authenticatedPage.waitForTimeout(2_000);
+ await authenticatedPage.waitForTimeout(2_400);
await submitButton.click();
@@ -315,7 +315,7 @@ test.describe('Proposal Submit Validation', () => {
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' });
- await expect(errorToast6).toBeVisible({ timeout: 5_000 });
+ await expect(errorToast6).toBeVisible({ timeout: 6_000 });
await expect(errorToast6).not.toContainText('Summary');
await expect(errorToast6).not.toContainText('Details');
await expect(errorToast6).toContainText('Priority Level');
@@ -336,7 +336,7 @@ test.describe('Proposal Submit Validation', () => {
await priorityField.getByRole('button', { name: 'Select option' }).click();
await authenticatedPage.getByRole('option', { name: 'High' }).click();
- await authenticatedPage.waitForTimeout(2_000);
+ await authenticatedPage.waitForTimeout(2_400);
await submitButton.click();
@@ -344,7 +344,7 @@ test.describe('Proposal Submit Validation', () => {
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' });
- await expect(errorToast7).toBeVisible({ timeout: 5_000 });
+ await expect(errorToast7).toBeVisible({ timeout: 6_000 });
await expect(errorToast7).not.toContainText('Priority Level');
await expect(errorToast7).toContainText('Region');
@@ -359,7 +359,7 @@ test.describe('Proposal Submit Validation', () => {
await regionField.getByRole('button', { name: 'Select option' }).click();
await authenticatedPage.getByRole('option', { name: 'North' }).click();
- await authenticatedPage.waitForTimeout(2_000);
+ await authenticatedPage.waitForTimeout(2_400);
await submitButton.click();
@@ -371,6 +371,6 @@ test.describe('Proposal Submit Validation', () => {
authenticatedPage
.locator('[data-sonner-toast]')
.filter({ hasText: 'Please complete the following required fields:' }),
- ).not.toBeVisible({ timeout: 5_000 });
+ ).not.toBeVisible({ timeout: 6_000 });
});
});