Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions .claude/commands/review-branch.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
---
description: Do a thorough code review of the current branch (or a GitHub PR)
argument-hint: [optional: github-pr-url]
allowed-tools: Task, Bash, Gh
---

Do a thorough code review of this branch. If an argument is passed and it is a github pull request, use `gh pr` to retrieve the pull request and review the pull request.
If there is no argument, you should review the current changes on this branch (you can diff against the dev branch).
run /pr-review-toolkit:review-pr all parallel
(note that our branches are never based on main. Usually dev or a stacked PR)
Always do this in planning mode and present the review at the end.

Arguments: $ARGUMENTS
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ tests/e2e/playwright-report/
tests/e2e/test-results/
tests/e2e/playwright/.auth/
.playwright-mcp
ralph.sh
17 changes: 17 additions & 0 deletions apps/app/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Module augmentation for next-intl.
* Wires the English dictionary as the canonical message type so that
* next-intl's internal type resolution (useTranslations, getTranslations)
* is aware of all valid keys. Our custom TranslateFn in routing.tsx is the
* client-facing contract; this augmentation supports next-intl internals.
* See: https://next-intl.dev/docs/workflows/typescript
*/
import type messages from './src/lib/i18n/dictionaries/en.json';

type Messages = typeof messages;

declare module 'next-intl' {
interface AppConfig {
Messages: Messages;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ProcessBuilderContent } from '@/components/decisions/ProcessBuilder/Pro
import { ProcessBuilderHeader } from '@/components/decisions/ProcessBuilder/ProcessBuilderHeader';
import { ProcessBuilderSidebar } from '@/components/decisions/ProcessBuilder/ProcessBuilderSectionNav';
import { ProcessBuilderStoreInitializer } from '@/components/decisions/ProcessBuilder/ProcessBuilderStoreInitializer';
import type { FormInstanceData } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore';
import type { ProcessBuilderInstanceData } from '@/components/decisions/ProcessBuilder/stores/useProcessBuilderStore';

const EditDecisionPage = async ({
params,
Expand All @@ -28,27 +28,19 @@ const EditDecisionPage = async ({
const instanceId = processInstance.id;
const instanceData = processInstance.instanceData;

// Map server data into the shape the store expects so validation works
// immediately — even before the user visits any section.
const serverData: FormInstanceData = {
// Seed the store with server data so validation works immediately.
const serverData: ProcessBuilderInstanceData = {
name: processInstance.name ?? undefined,
description: processInstance.description ?? undefined,
stewardProfileId: processInstance.steward?.id,
phases: instanceData.phases,
proposalTemplate:
instanceData.proposalTemplate as FormInstanceData['proposalTemplate'],
hideBudget: instanceData.config?.hideBudget,
categories: instanceData.config?.categories,
requireCategorySelection: instanceData.config?.requireCategorySelection,
allowMultipleCategories: instanceData.config?.allowMultipleCategories,
organizeByCategories: instanceData.config?.organizeByCategories,
requireCollaborativeProposals:
instanceData.config?.requireCollaborativeProposals,
isPrivate: instanceData.config?.isPrivate,
instanceData.proposalTemplate as ProcessBuilderInstanceData['proposalTemplate'],
config: instanceData.config,
};

return (
<div className="bg-background relative flex h-dvh w-full flex-1 flex-col">
<div className="bg-background relative flex h-dvh w-full flex-1 flex-col overflow-y-hidden">
<ProcessBuilderStoreInitializer
decisionProfileId={decisionProfile.id}
serverData={serverData}
Expand All @@ -57,7 +49,7 @@ const EditDecisionPage = async ({
<ProcessBuilderHeader instanceId={instanceId} slug={slug} />
<div className="flex min-h-0 grow flex-col overflow-y-auto md:flex-row md:overflow-y-hidden">
<ProcessBuilderSidebar instanceId={instanceId} />
<main className="h-full grow overflow-y-auto [scrollbar-gutter:stable]">
<main className="h-full grow overflow-y-auto">
<ProcessBuilderContent
decisionProfileId={decisionProfile.id}
instanceId={instanceId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { trpc } from '@op/api/client';
import { ProcessStatus } from '@op/api/encoders';
import { getTextPreview } from '@op/core';
import { ButtonLink } from '@op/ui/Button';
import { LoadingSpinner } from '@op/ui/LoadingSpinner';
import {
NotificationPanel,
NotificationPanelActions,
Expand Down Expand Up @@ -62,8 +61,9 @@ const ActiveDecisionsNotificationsSuspense = () => {
className="w-full sm:w-auto"
href={`/decisions/${decision.slug}`}
onPress={() => setNavigatingId(decision.id)}
isLoading={isNavigating}
>
{isNavigating ? <LoadingSpinner /> : t('Participate')}
{t('Participate')}
</ButtonLink>
</NotificationPanelActions>
</NotificationPanelItem>
Expand Down
52 changes: 52 additions & 0 deletions apps/app/src/components/ConfirmDeleteModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Button } from '@op/ui/Button';
import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal';

import { useTranslations } from '@/lib/i18n';

export function ConfirmDeleteModal({
isOpen,
title,
message,
onConfirm,
onCancel,
}: {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}) {
const t = useTranslations();
return (
<Modal
isDismissable
isOpen={isOpen}
onOpenChange={(open) => {
if (!open) {
onCancel();
}
}}
>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
<p>{message}</p>
</ModalBody>
<ModalFooter>
<Button
color="secondary"
className="w-full sm:w-fit"
onPress={onCancel}
>
{t('Cancel')}
</Button>
<Button
color="destructive"
className="w-full sm:w-fit"
onPress={onConfirm}
>
{t('Delete')}
</Button>
</ModalFooter>
</Modal>
);
}
2 changes: 1 addition & 1 deletion apps/app/src/components/DeleteOrganizationModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ const SelectProfileStep = ({
<div className="flex flex-col gap-4 px-6 py-4">
<p id="select-accounts-label">
{t(
'Please select the account youd like to delete. This action cannot be undone.',
"Please select the account you'd like to delete. This action cannot be undone.",
)}
</p>
<RadioGroup
Expand Down
3 changes: 1 addition & 2 deletions apps/app/src/components/InviteUserModal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,7 @@ export const InviteUserModal = ({
if (errorInfo.isConnectionError) {
toast.error({
title: t('Connection issue'),
message:
errorInfo.message + ' ' + t('Please try sending the invite again.'),
message: t('Please try sending the invite again.'),
});
} else {
toast.error({
Expand Down
10 changes: 6 additions & 4 deletions apps/app/src/components/Onboarding/FundingInformationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LuLink } from 'react-icons/lu';
import { z } from 'zod';

import { useTranslations } from '@/lib/i18n';
import type { TranslateFn } from '@/lib/i18n';

import type { StepProps } from '../MultiStepForm';
import { TermsMultiSelect } from '../TermsMultiSelect';
Expand All @@ -15,7 +16,7 @@ import { ToggleRow } from '../layout/split/form/ToggleRow';
import { multiSelectOptionValidator } from './shared/organizationValidation';
import { useOnboardingFormStore } from './useOnboardingFormStore';

const createFundingValidator = (t: (key: string) => string) =>
const createFundingValidator = (t: TranslateFn) =>
z.object({
isReceivingFunds: z.boolean().prefault(false).optional(),
isOfferingFunds: z.boolean().prefault(false).optional(),
Expand All @@ -42,17 +43,18 @@ const createFundingValidator = (t: (key: string) => string) =>
}),
});

// Static validator for type inference and external schema composition
// Static validator for type inference and external schema composition.
// Must mirror createFundingValidator's structure (without translated error messages).
export const validator = z.object({
isReceivingFunds: z.boolean().prefault(false).optional(),
isOfferingFunds: z.boolean().prefault(false).optional(),
acceptingApplications: z.boolean().prefault(false).optional(),
receivingFundsDescription: z.string().max(200).optional(),
receivingFundsTerms: z.array(multiSelectOptionValidator).optional(),
receivingFundsLink: z.string().optional(),
receivingFundsLink: zodUrl({ error: 'Enter a valid website address' }),
offeringFundsTerms: z.array(multiSelectOptionValidator).optional(),
offeringFundsDescription: z.string().max(200).optional(),
offeringFundsLink: z.string().optional(),
offeringFundsLink: zodUrl({ error: 'Enter a valid website address' }),
});

export const FundingInformationForm = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const MatchingOrganizationsForm = ({
<div>{t('Confirm Administrator Access')}</div>
<div>
{t(
"For now, we're only supporting administrator accounts. In the future, well 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.",
)}
</div>

Expand Down
7 changes: 4 additions & 3 deletions apps/app/src/components/Onboarding/PersonalDetailsForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ReactNode, Suspense, useState } from 'react';
import { z } from 'zod';

import { useTranslations } from '@/lib/i18n';
import type { TranslateFn } from '@/lib/i18n';

import { StepProps } from '../MultiStepForm';
import { FocusAreasField } from '../Profile/ProfileDetails/FocusAreasField';
Expand All @@ -22,7 +23,7 @@ import { useOnboardingFormStore } from './useOnboardingFormStore';

type FormFields = z.infer<typeof validator>;

export const createValidator = (t: (key: string) => string) =>
export const createValidator = (t: TranslateFn) =>
z
.object({
fullName: z
Expand Down Expand Up @@ -163,8 +164,8 @@ export const PersonalDetailsForm = ({
if (file.size > DEFAULT_MAX_SIZE) {
const maxSizeMB = (DEFAULT_MAX_SIZE / 1024 / 1024).toFixed(2);
toast.error({
message: t('File too large. Maximum size: {maxSizeMB}MB', {
maxSizeMB,
message: t('File too large. Maximum size: {size}MB', {
size: maxSizeMB,
}),
});
return;
Expand Down
5 changes: 1 addition & 4 deletions apps/app/src/components/Onboarding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,7 @@ export const OnboardingFlow = () => {
if (errorInfo.isConnectionError) {
toast.error({
title: t('Connection issue'),
message:
errorInfo.message +
' ' +
t('Please try submitting the form again.'),
message: t('Please try submitting the form again.'),
});
} else {
toast.error({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,8 @@ export const OrganizationFormFields = ({
if (file.size > DEFAULT_MAX_SIZE) {
const maxSizeMB = (DEFAULT_MAX_SIZE / 1024 / 1024).toFixed(2);
toast.error({
message: t('File too large. Maximum size: {maxSizeMB}MB', {
maxSizeMB,
message: t('File too large. Maximum size: {size}MB', {
size: maxSizeMB,
}),
});
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { zodUrl } from '@op/common/validation';
import { z } from 'zod';

import type { TranslateFn } from '@/lib/i18n';

export const multiSelectOptionValidator = z.object({
id: z.string(),
label: z.string().max(200),
isNewValue: z.boolean().prefault(false).optional(),
data: z.record(z.string(), z.any()).prefault({}),
});

export const createOrganizationFormValidator = (t: (key: string) => string) =>
export const createOrganizationFormValidator = (t: TranslateFn) =>
z.object({
name: z
.string({
Expand Down Expand Up @@ -66,10 +68,11 @@ export const createOrganizationFormValidator = (t: (key: string) => string) =>
orgBannerImageId: z.string().optional(),
});

// Static validator for type inference and external schema composition
// Static validator for type inference and external schema composition.
// Must mirror createOrganizationFormValidator's structure (without translated error messages).
export const organizationFormValidator = z.object({
name: z.string().min(1).max(100),
website: z.string().optional(),
website: zodUrl({ isRequired: true, error: 'Enter a valid website address' }),
email: z.email().max(200),
orgType: z.string().max(200).min(1),
bio: z.string().max(150).min(1),
Expand Down
38 changes: 24 additions & 14 deletions apps/app/src/components/OrganizationsSearchResults/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,14 @@ export const ProfileSearchResultsSuspense = ({
return totalResults > 0 ? (
<>
<ListPageLayoutHeader>
<span className="text-neutral-gray4">{t('Results for')}</span>{' '}
<span className="text-neutral-black">{query}</span>
<span className="text-neutral-gray4">
{t.rich('Results for <highlight>{query}</highlight>', {
query: query,
highlight: (chunks: React.ReactNode) => (
<span className="text-neutral-black">{chunks}</span>
),
})}
</span>
</ListPageLayoutHeader>
{individualSearchEnabled ? (
<TabbedProfileSearchResults profiles={profileSearchResults} />
Expand All @@ -58,8 +64,14 @@ export const ProfileSearchResultsSuspense = ({
) : (
<>
<ListPageLayoutHeader className="flex justify-center gap-2">
<span className="text-neutral-gray4">{t('No results for')}</span>{' '}
<span className="text-neutral-black">{query}</span>
<span className="text-neutral-gray4">
{t.rich('No results for <highlight>{query}</highlight>', {
query: query,
highlight: (chunks: React.ReactNode) => (
<span className="text-neutral-black">{chunks}</span>
),
})}
</span>
</ListPageLayoutHeader>
<div className="flex justify-center">
<span className="max-w-96 text-center text-neutral-black">
Expand Down Expand Up @@ -88,32 +100,30 @@ export const TabbedProfileSearchResults = ({
<Tabs key={defaultSelectedKey} defaultSelectedKey={defaultSelectedKey}>
<TabList variant="pill">
{profiles.map(({ type, results }) => {
const typeName = match(type, {
[EntityType.INDIVIDUAL]: 'Individual',
[EntityType.ORG]: 'Organization',
const label = match(type, {
[EntityType.INDIVIDUAL]: t('Individuals'),
[EntityType.ORG]: t('Organizations'),
});
return (
<Tab id={type} variant="pill" className="gap-2" key={`${type}-tab`}>
{t(typeName)}s
{label}
<span className="text-neutral-gray4">{results.length}</span>
</Tab>
);
})}
</TabList>
{profiles.map(({ type, results }) => {
const typeName = match(type, {
[EntityType.INDIVIDUAL]: 'Individual',
[EntityType.ORG]: 'Organization',
const label = match(type, {
[EntityType.INDIVIDUAL]: t('individuals'),
[EntityType.ORG]: t('organizations'),
});
return (
<TabPanel key={`${type}-panel`} id={type}>
{results.length > 0 ? (
<ProfileSummaryList profiles={results} />
) : (
<div className="mt-2 w-full rounded p-8 text-center text-neutral-gray4">
{t('No {type} found.', {
type: t(typeName).toLocaleLowerCase() + 's',
})}
{t('No {type} found.', { type: label })}
</div>
)}
</TabPanel>
Expand Down
Loading
Loading