diff --git a/apps/app/src/components/IconProvider.tsx b/apps/app/src/components/IconProvider.tsx index f0622fbf7..8a3616624 100644 --- a/apps/app/src/components/IconProvider.tsx +++ b/apps/app/src/components/IconProvider.tsx @@ -4,7 +4,9 @@ import { IconContext } from 'react-icons'; export const IconProvider = ({ children }: { children: React.ReactNode }) => { return ( - + {children} ); diff --git a/apps/app/src/components/RichTextEditor/RichTextEditorFloatingToolbar.tsx b/apps/app/src/components/RichTextEditor/RichTextEditorFloatingToolbar.tsx index 650a7b991..11586f7f7 100644 --- a/apps/app/src/components/RichTextEditor/RichTextEditorFloatingToolbar.tsx +++ b/apps/app/src/components/RichTextEditor/RichTextEditorFloatingToolbar.tsx @@ -1,24 +1,24 @@ 'use client'; import type { Editor } from '@tiptap/react'; -import { - AlignCenter, - AlignLeft, - AlignRight, - Bold, - Code, - Heading1, - Heading2, - Heading3, - Italic, - Link as LinkIcon, - List, - ListOrdered, - Quote, - Strikethrough, - Underline as UnderlineIcon, -} from 'lucide-react'; import { useCallback } from 'react'; +import { + LuAlignCenter, + LuAlignLeft, + LuAlignRight, + LuBold, + LuCode, + LuHeading1, + LuHeading2, + LuHeading3, + LuItalic, + LuLink, + LuList, + LuListOrdered, + LuQuote, + LuStrikethrough, + LuUnderline, +} from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -81,7 +81,7 @@ export function RichTextEditorFloatingToolbar({ className={`rounded p-1.5 hover:bg-gray-100 ${editor.isActive('heading', { level: 1 }) ? 'bg-gray-200 text-neutral-black' : 'text-neutral-charcoal'}`} title={t('Heading 1')} > - +
@@ -115,7 +115,7 @@ export function RichTextEditorFloatingToolbar({ className={`rounded p-1.5 hover:bg-gray-100 ${editor.isActive('bold') ? 'bg-gray-200 text-neutral-black' : 'text-neutral-charcoal'}`} title={t('Bold')} > - +
@@ -169,7 +169,7 @@ export function RichTextEditorFloatingToolbar({ className={`rounded p-1.5 hover:bg-gray-100 ${editor.isActive('bulletList') ? 'bg-gray-200 text-neutral-black' : 'text-neutral-charcoal'}`} title={t('Bullet List')} > - +
@@ -203,7 +203,7 @@ export function RichTextEditorFloatingToolbar({ className={`rounded p-1.5 hover:bg-gray-100 ${editor.isActive({ textAlign: 'left' }) ? 'bg-gray-200 text-neutral-black' : 'text-neutral-charcoal'}`} title={t('Align Left')} > - +
@@ -237,7 +237,7 @@ export function RichTextEditorFloatingToolbar({ className={`rounded p-1.5 hover:bg-gray-100 ${editor.isActive('link') ? 'bg-gray-200 text-neutral-black' : 'text-neutral-charcoal'}`} title={t('Add Link')} > - +
); diff --git a/apps/app/src/components/RichTextEditor/RichTextEditorToolbar.tsx b/apps/app/src/components/RichTextEditor/RichTextEditorToolbar.tsx index 95275d003..bed1d9241 100644 --- a/apps/app/src/components/RichTextEditor/RichTextEditorToolbar.tsx +++ b/apps/app/src/components/RichTextEditor/RichTextEditorToolbar.tsx @@ -1,30 +1,31 @@ 'use client'; import { useFileUpload } from '@/hooks/useFileUpload'; +import { cn } from '@op/ui/utils'; import type { Editor } from '@tiptap/react'; -import { - AlignCenter, - AlignLeft, - AlignRight, - Bold, - Code, - Heading1, - Heading2, - Heading3, - Image as ImageIcon, - Italic, - Link2, - Link as LinkIcon, - List, - ListOrdered, - Minus, - Quote, - Redo, - Strikethrough, - Underline as UnderlineIcon, - Undo, -} from 'lucide-react'; import { useCallback, useRef } from 'react'; +import { + LuAlignCenter, + LuAlignLeft, + LuAlignRight, + LuBold, + LuCode, + LuHeading1, + LuHeading2, + LuHeading3, + LuImage, + LuItalic, + LuLink, + LuLink2, + LuList, + LuListOrdered, + LuMinus, + LuQuote, + LuRedo, + LuStrikethrough, + LuUnderline, + LuUndo, +} from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -110,7 +111,12 @@ export function RichTextEditorToolbar({ `shrink-0 rounded p-2 hover:bg-gray-100 ${active ? 'bg-gray-200' : ''}`; return ( -
+
{/* Undo/Redo */}
@@ -143,7 +149,7 @@ export function RichTextEditorToolbar({ )} title={t('Heading 1')} > - +
@@ -179,7 +185,7 @@ export function RichTextEditorToolbar({ className={btnClass(editor?.isActive('bold') ?? false)} title={t('Bold')} > - +
@@ -223,7 +229,7 @@ export function RichTextEditorToolbar({ className={btnClass(editor?.isActive('bulletList') ?? false)} title={t('Bullet List')} > - +
@@ -251,7 +257,7 @@ export function RichTextEditorToolbar({ className={btnClass(editor?.isActive({ textAlign: 'left' }) ?? false)} title={t('Align Left')} > - +
@@ -283,7 +289,7 @@ export function RichTextEditorToolbar({ className={btnClass(editor?.isActive('link') ?? false)} title={t('Add Link')} > - +
diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx index 1a0029f02..988a00738 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/participants/ParticipantsSection.tsx @@ -4,11 +4,15 @@ import type { SectionProps } from '../../contentRegistry'; export default function ParticipantsSection({ decisionProfileId, + decisionName, }: SectionProps) { return (
- +
); diff --git a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorSidebar.tsx b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorSidebar.tsx index a6a1e7d4f..38ffee3bd 100644 --- a/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorSidebar.tsx +++ b/apps/app/src/components/decisions/ProcessBuilder/stepContent/template/TemplateEditorSidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import { Button } from '@op/ui/Button'; +import { Header4 } from '@op/ui/Header'; import { Sidebar, useSidebar } from '@op/ui/Sidebar'; import type { IconType } from 'react-icons'; import { LuAlignJustify } from 'react-icons/lu'; @@ -96,9 +97,7 @@ function SidebarContent({
-

- {t('Fields')} -

+ {t('Fields')}
    {fields.map((field) => { const Icon = @@ -109,7 +108,7 @@ function SidebarContent({ } return (
  • -
    +
    {field.label}
    diff --git a/apps/app/src/components/decisions/ProfileUsersAccess.tsx b/apps/app/src/components/decisions/ProfileUsersAccess.tsx index d79c6ea92..de179bcdf 100644 --- a/apps/app/src/components/decisions/ProfileUsersAccess.tsx +++ b/apps/app/src/components/decisions/ProfileUsersAccess.tsx @@ -23,7 +23,13 @@ type SortColumn = 'name' | 'email' | 'role'; const ITEMS_PER_PAGE = 25; -export const ProfileUsersAccess = ({ profileId }: { profileId: string }) => { +export const ProfileUsersAccess = ({ + profileId, + processName, +}: { + profileId: string; + processName?: string; +}) => { const t = useTranslations(); const isMobile = useMediaQuery(`(max-width: ${screens.md})`); const [searchQuery, setSearchQuery] = useState(''); @@ -119,6 +125,7 @@ export const ProfileUsersAccess = ({ profileId }: { profileId: string }) => { roles={roles} isMobile={isMobile} invites={invites ?? []} + processName={processName} /> { const t = useTranslations(); @@ -75,6 +80,7 @@ export const ProfileUsersAccessTable = ({ isLoading={isLoading} roles={roles} invites={invites} + processName={processName} /> ); } @@ -88,6 +94,7 @@ export const ProfileUsersAccessTable = ({ isLoading={isLoading} roles={roles} invites={invites} + processName={processName} /> ); }; @@ -103,16 +110,21 @@ const ProfileUserRoleSelect = ({ currentRoleId, profileId, roles, + userName, + processName, className = 'sm:w-32', }: { profileUserId: string; currentRoleId?: string; profileId: string; roles: { id: string; name: string }[]; + userName: string; + processName?: string; className?: string; }) => { const t = useTranslations(); const utils = trpc.useUtils(); + const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false); const updateRoles = trpc.profile.updateUserRoles.useMutation({ onSuccess: () => { @@ -126,6 +138,19 @@ const ProfileUserRoleSelect = ({ }, }); + const removeUser = trpc.profile.removeUser.useMutation({ + onSuccess: () => { + toast.success({ message: t('User removed from process') }); + void utils.profile.listUsers.invalidate({ profileId }); + setIsRemoveModalOpen(false); + }, + onError: (error) => { + toast.error({ + message: error.message || t('Failed to remove user'), + }); + }, + }); + const handleRoleChange = (roleId: string) => { if (roleId && roleId !== currentRoleId) { updateRoles.mutate({ @@ -135,21 +160,73 @@ const ProfileUserRoleSelect = ({ } }; + const isPending = updateRoles.isPending || removeUser.isPending; + return ( - { + const keyStr = key as string; + if (keyStr === 'remove') { + setIsRemoveModalOpen(true); + } else { + handleRoleChange(keyStr); + } + }} + isDisabled={isPending} + size="small" + className={className} + > + {roles.map((role) => ( + + {role.name} + + ))} + + {t('Remove from process')} - ))} - + + + + {t('Remove {name}', { name: userName })} + +

    + {processName + ? t( + 'Are you sure you want to remove {name} from "{processName}"?', + { name: userName, processName }, + ) + : t( + 'Are you sure you want to remove {name} from this process?', + { name: userName }, + )} +

    +
    + + + + +
    +
    + ); }; @@ -158,16 +235,21 @@ const InviteRoleSelect = ({ currentRoleId, profileId, roles, + inviteeName, + processName, className = 'sm:w-32', }: { inviteId: string; currentRoleId: string; profileId: string; roles: { id: string; name: string }[]; + inviteeName: string; + processName?: string; className?: string; }) => { const t = useTranslations(); const utils = trpc.useUtils(); + const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false); const updateInvite = trpc.profile.updateProfileInvite.useMutation({ onSuccess: () => { @@ -181,6 +263,19 @@ const InviteRoleSelect = ({ }, }); + const deleteInvite = trpc.profile.deleteProfileInvite.useMutation({ + onSuccess: () => { + toast.success({ message: t('Invite removed from process') }); + void utils.profile.listProfileInvites.invalidate({ profileId }); + setIsRemoveModalOpen(false); + }, + onError: (error) => { + toast.error({ + message: error.message || t('Failed to remove invite'), + }); + }, + }); + const handleRoleChange = (roleId: string) => { if (roleId && roleId !== currentRoleId) { updateInvite.mutate({ @@ -190,21 +285,73 @@ const InviteRoleSelect = ({ } }; + const isPending = updateInvite.isPending || deleteInvite.isPending; + return ( - { + const keyStr = key as string; + if (keyStr === 'remove') { + setIsRemoveModalOpen(true); + } else { + handleRoleChange(keyStr); + } + }} + isDisabled={isPending} + size="small" + className={className} + > + {roles.map((role) => ( + + {role.name} + + ))} + + {t('Remove from process')} - ))} - + + + + {t('Remove {name}', { name: inviteeName })} + +

    + {processName + ? t( + 'Are you sure you want to remove {name} from "{processName}"?', + { name: inviteeName, processName }, + ) + : t( + 'Are you sure you want to remove {name} from this process?', + { name: inviteeName }, + )} +

    +
    + + + + +
    +
    + ); }; @@ -212,10 +359,12 @@ const MobileProfileUserCard = ({ profileUser, profileId, roles, + processName, }: { profileUser: ProfileUser; profileId: string; roles: { id: string; name: string }[]; + processName?: string; }) => { const displayName = profileUser.profile?.name || @@ -256,6 +405,8 @@ const MobileProfileUserCard = ({ currentRoleId={currentRole?.id} profileId={profileId} roles={roles} + userName={displayName} + processName={processName} className="w-full" />
    @@ -266,10 +417,12 @@ const MobileInviteCard = ({ invite, profileId, roles, + processName, }: { invite: ProfileInvite; profileId: string; roles: { id: string; name: string }[]; + processName?: string; }) => { const t = useTranslations(); const displayName = invite.inviteeProfile?.name ?? invite.email; @@ -293,6 +446,8 @@ const MobileInviteCard = ({ currentRoleId={invite.accessRoleId} profileId={profileId} roles={roles} + inviteeName={displayName} + processName={processName} className="w-full" />
@@ -305,12 +460,14 @@ const MobileProfileUsersContent = ({ isLoading, roles, invites, + processName, }: { profileUsers: ProfileUser[]; profileId: string; isLoading: boolean; roles: { id: string; name: string }[]; invites: ProfileInvite[]; + processName?: string; }) => { return (
@@ -323,6 +480,7 @@ const MobileProfileUsersContent = ({ invite={invite} profileId={profileId} roles={roles} + processName={processName} /> ))} {profileUsers.map((profileUser) => ( @@ -331,6 +489,7 @@ const MobileProfileUsersContent = ({ profileUser={profileUser} profileId={profileId} roles={roles} + processName={processName} /> ))} @@ -348,6 +507,7 @@ const ProfileUsersAccessTableContent = ({ isLoading, roles, invites, + processName, }: { profileUsers: ProfileUser[]; profileId: string; @@ -356,6 +516,7 @@ const ProfileUsersAccessTableContent = ({ isLoading: boolean; roles: { id: string; name: string }[]; invites: ProfileInvite[]; + processName?: string; }) => { const t = useTranslations(); @@ -413,6 +574,8 @@ const ProfileUsersAccessTableContent = ({ currentRoleId={invite.accessRoleId} profileId={profileId} roles={roles} + inviteeName={displayName} + processName={processName} /> @@ -462,6 +625,8 @@ const ProfileUsersAccessTableContent = ({ currentRoleId={currentRole?.id} profileId={profileId} roles={roles} + userName={displayName} + processName={processName} /> diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardActions.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardActions.tsx index 67cbcdda7..a4752fee9 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardActions.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardActions.tsx @@ -7,9 +7,8 @@ import { Button, ButtonLink } from '@op/ui/Button'; import { DialogTrigger } from '@op/ui/Dialog'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; import { toast } from '@op/ui/Toast'; -import { Heart, Pencil, Trash2 } from 'lucide-react'; import { useState } from 'react'; -import { LuBookmark } from 'react-icons/lu'; +import { LuBookmark, LuHeart, LuPencil, LuTrash2 } from 'react-icons/lu'; import type { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; @@ -61,7 +60,7 @@ export function ProposalCardActions({ className="w-full text-nowrap" isDisabled={isLoading} > - + {isLikedByUser ? t('Liked') : t('Like')} diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx index 8c99c8534..2257369b7 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardComponents.tsx @@ -16,10 +16,9 @@ import { Avatar } from '@op/ui/Avatar'; import { Chip } from '@op/ui/Chip'; import { Surface } from '@op/ui/Surface'; import { cn } from '@op/ui/utils'; -import { Heart, MessageCircle } from 'lucide-react'; import Image from 'next/image'; import type { HTMLAttributes, ReactNode } from 'react'; -import { LuBookmark } from 'react-icons/lu'; +import { LuBookmark, LuHeart, LuMessageCircle } from 'react-icons/lu'; import type { z } from 'zod'; import { useTranslations } from '@/lib/i18n'; @@ -53,7 +52,7 @@ export function ProposalCard({ {displayText}

@@ -412,11 +408,11 @@ export function ProposalCardMetrics({ )} > - + {proposal.likesCount || 0} {t('Likes')} - + {proposal.commentsCount || 0} {t('Comments')} diff --git a/apps/app/src/components/decisions/ProposalCard/ProposalCardMenu.tsx b/apps/app/src/components/decisions/ProposalCard/ProposalCardMenu.tsx index b17f30ef4..1efdd9165 100644 --- a/apps/app/src/components/decisions/ProposalCard/ProposalCardMenu.tsx +++ b/apps/app/src/components/decisions/ProposalCard/ProposalCardMenu.tsx @@ -14,8 +14,8 @@ import { Menu, MenuItem, MenuTrigger } from '@op/ui/Menu'; import { Modal, ModalBody, ModalFooter, ModalHeader } from '@op/ui/Modal'; import { Popover } from '@op/ui/Popover'; import { toast } from '@op/ui/Toast'; -import { Trash2 } from 'lucide-react'; import { useState } from 'react'; +import { LuTrash2 } from 'react-icons/lu'; import { LuCheck, LuEllipsis, LuEye, LuEyeOff, LuX } from 'react-icons/lu'; import type { z } from 'zod'; @@ -254,7 +254,7 @@ export function ProposalCardMenu({ if (proposal.isEditable) { items.push({ key: 'delete', - icon: , + icon: , label: t('Delete'), onAction: () => { setIsMenuSheetOpen(false); diff --git a/apps/app/src/components/decisions/ProposalEditorLayout.tsx b/apps/app/src/components/decisions/ProposalEditorLayout.tsx index e24edd62d..611189d7d 100644 --- a/apps/app/src/components/decisions/ProposalEditorLayout.tsx +++ b/apps/app/src/components/decisions/ProposalEditorLayout.tsx @@ -1,6 +1,7 @@ 'use client'; import { Button } from '@op/ui/Button'; +import { Header4 } from '@op/ui/Header'; import { LoadingSpinner } from '@op/ui/LoadingSpinner'; import { useRouter } from 'next/navigation'; import { ReactNode, useState } from 'react'; @@ -61,10 +62,10 @@ export function ProposalEditorLayout({ {t('Back')} -
- +
+ {title ? title : t('Untitled Proposal')} - +
diff --git a/apps/app/src/components/decisions/ProposalView.tsx b/apps/app/src/components/decisions/ProposalView.tsx index 092eaf587..98ffbeb5a 100644 --- a/apps/app/src/components/decisions/ProposalView.tsx +++ b/apps/app/src/components/decisions/ProposalView.tsx @@ -14,11 +14,10 @@ import { Header1 } from '@op/ui/Header'; import { Link } from '@op/ui/Link'; import { Surface } from '@op/ui/Surface'; 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, useMemo, useRef, useState } from 'react'; -import { LuBookmark } from 'react-icons/lu'; +import { LuBookmark, LuHeart, LuMessageCircle } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -283,13 +282,13 @@ export function ProposalView({ {/* Engagement Stats */}
- + {currentProposal.likesCount || 0} {t('Likes')}
- + {currentProposal.commentsCount || 0}{' '} {(currentProposal.commentsCount || 0) !== 1 diff --git a/apps/app/src/components/decisions/ProposalViewLayout.tsx b/apps/app/src/components/decisions/ProposalViewLayout.tsx index 297b50776..d82a9f3a7 100644 --- a/apps/app/src/components/decisions/ProposalViewLayout.tsx +++ b/apps/app/src/components/decisions/ProposalViewLayout.tsx @@ -1,9 +1,8 @@ 'use client'; import { Button } from '@op/ui/Button'; -import { Edit, Heart } from 'lucide-react'; import { ReactNode } from 'react'; -import { LuArrowLeft, LuBookmark } from 'react-icons/lu'; +import { LuArrowLeft, LuBookmark, LuHeart, LuPencil } from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; import { useRouter } from '@/lib/i18n/routing'; @@ -62,7 +61,7 @@ export function ProposalViewLayout({ onPress={() => router.push(editHref)} className="px-4 py-2" > - + {t('Edit')} )} @@ -73,7 +72,7 @@ export function ProposalViewLayout({ onPress={onLike} isDisabled={isLoading} > - + {isLiked ? t('Liked') : t('Like')} ) diff --git a/apps/app/src/components/decisions/SlashCommands.tsx b/apps/app/src/components/decisions/SlashCommands.tsx index a00252895..a8c5be3c7 100644 --- a/apps/app/src/components/decisions/SlashCommands.tsx +++ b/apps/app/src/components/decisions/SlashCommands.tsx @@ -3,18 +3,6 @@ import { Extension } from '@tiptap/core'; import { PluginKey } from '@tiptap/pm/state'; import { Suggestion, SuggestionOptions } from '@tiptap/suggestion'; -import { - Code, - Heading1, - Heading2, - Heading3, - Link2, - List, - ListOrdered, - Minus, - Quote, - Type, -} from 'lucide-react'; import React, { forwardRef, useEffect, @@ -22,6 +10,18 @@ import React, { useState, } from 'react'; import { createRoot } from 'react-dom/client'; +import { + LuCode, + LuHeading1, + LuHeading2, + LuHeading3, + LuLink2, + LuList, + LuListOrdered, + LuMinus, + LuQuote, + LuType, +} from 'react-icons/lu'; import { useTranslations } from '@/lib/i18n'; @@ -125,7 +125,7 @@ const suggestionOptions: Partial = { title: 'Text', description: 'Just start typing with plain text.', searchTerms: ['p', 'paragraph'], - icon: Type, + icon: LuType, command: ({ editor, range }) => { editor .chain() @@ -139,7 +139,7 @@ const suggestionOptions: Partial = { title: 'Heading 1', description: 'Big section heading.', searchTerms: ['title', 'big', 'large'], - icon: Heading1, + icon: LuHeading1, command: ({ editor, range }) => { editor .chain() @@ -153,7 +153,7 @@ const suggestionOptions: Partial = { title: 'Heading 2', description: 'Medium section heading.', searchTerms: ['subtitle', 'medium'], - icon: Heading2, + icon: LuHeading2, command: ({ editor, range }) => { editor .chain() @@ -167,7 +167,7 @@ const suggestionOptions: Partial = { title: 'Heading 3', description: 'Small section heading.', searchTerms: ['subtitle', 'small'], - icon: Heading3, + icon: LuHeading3, command: ({ editor, range }) => { editor .chain() @@ -181,7 +181,7 @@ const suggestionOptions: Partial = { title: 'Bullet List', description: 'Create a simple bullet list.', searchTerms: ['unordered', 'point'], - icon: List, + icon: LuList, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, @@ -190,7 +190,7 @@ const suggestionOptions: Partial = { title: 'Numbered List', description: 'Create a list with numbering.', searchTerms: ['ordered'], - icon: ListOrdered, + icon: LuListOrdered, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, @@ -199,7 +199,7 @@ const suggestionOptions: Partial = { title: 'Quote', description: 'Capture a quote.', searchTerms: ['blockquote'], - icon: Quote, + icon: LuQuote, command: ({ editor, range }) => { editor .chain() @@ -214,7 +214,7 @@ const suggestionOptions: Partial = { title: 'Code', description: 'Capture a code snippet.', searchTerms: ['codeblock'], - icon: Code, + icon: LuCode, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); }, @@ -223,7 +223,7 @@ const suggestionOptions: Partial = { title: 'Divider', description: 'Visually divide blocks.', searchTerms: ['horizontal', 'rule', 'hr'], - icon: Minus, + icon: LuMinus, command: ({ editor, range }) => { editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, @@ -232,7 +232,7 @@ const suggestionOptions: Partial = { title: 'Link Embed', description: 'Embed a link with preview.', searchTerms: ['embed', 'preview', 'iframely', 'url'], - icon: Link2, + icon: LuLink2, command: ({ editor, range }) => { const url = window.prompt('Enter the URL to embed:'); if (url && url.trim()) { diff --git a/apps/app/src/lib/i18n/dictionaries/bn.json b/apps/app/src/lib/i18n/dictionaries/bn.json index a2e9e19fc..97c06d395 100644 --- a/apps/app/src/lib/i18n/dictionaries/bn.json +++ b/apps/app/src/lib/i18n/dictionaries/bn.json @@ -228,6 +228,10 @@ "Change to Member": "সদস্যে পরিবর্তন করুন", "Change to Admin": "প্রশাসক করুন", "Remove from organization": "সংস্থা থেকে সরান", + "Remove from process": "প্রক্রিয়া থেকে সরান", + "User removed from process": "ব্যবহারকারী প্রক্রিয়া থেকে সরানো হয়েছে", + "Invite removed from process": "আমন্ত্রণ প্রক্রিয়া থেকে সরানো হয়েছে", + "Failed to remove invite": "আমন্ত্রণ সরাতে ব্যর্থ", "Member": "সদস্য", "User changed to Admin successfully": "ব্যবহারকারীকে সফলভাবে প্রশাসকে পরিবর্তন করা হয়েছে", "User changed to Member successfully": "ব্যবহারকারীকে সফলভাবে সদস্যে পরিবর্তন করা হয়েছে", @@ -584,6 +588,8 @@ "Selected members": "নির্বাচিত সদস্যরা", "No email addresses to invite": "আমন্ত্রণ করার জন্য কোনো ইমেইল ঠিকানা নেই", "Remove {name}": "{name} সরান", + "Are you sure you want to remove {name} from \"{processName}\"?": "আপনি কি নিশ্চিত যে আপনি {name} কে \"{processName}\" থেকে সরাতে চান?", + "Are you sure you want to remove {name} from this process?": "আপনি কি নিশ্চিত যে আপনি {name} কে এই প্রক্রিয়া থেকে সরাতে চান?", "Select a role": "একটি ভূমিকা নির্বাচন করুন", "Add role": "ভূমিকা যোগ করুন", "Admin": "অ্যাডমিন", diff --git a/apps/app/src/lib/i18n/dictionaries/en.json b/apps/app/src/lib/i18n/dictionaries/en.json index 888431b00..2cb64f097 100644 --- a/apps/app/src/lib/i18n/dictionaries/en.json +++ b/apps/app/src/lib/i18n/dictionaries/en.json @@ -228,6 +228,10 @@ "Change to Member": "Change to Member", "Change to Admin": "Change to Admin", "Remove from organization": "Remove from organization", + "Remove from process": "Remove from process", + "User removed from process": "User removed from process", + "Invite removed from process": "Invite removed from process", + "Failed to remove invite": "Failed to remove invite", "Member": "Member", "User changed to Admin successfully": "User changed to Admin successfully", "User changed to Member successfully": "User changed to Member successfully", @@ -577,6 +581,8 @@ "Selected members": "Selected members", "No email addresses to invite": "No email addresses to invite", "Remove {name}": "Remove {name}", + "Are you sure you want to remove {name} from \"{processName}\"?": "Are you sure you want to remove {name} from \"{processName}\"?", + "Are you sure you want to remove {name} from this process?": "Are you sure you want to remove {name} from this process?", "Select a role": "Select a role", "Add role": "Add role", "Admin": "Admin", diff --git a/apps/app/src/lib/i18n/dictionaries/es.json b/apps/app/src/lib/i18n/dictionaries/es.json index b40d0ebd7..f4c562612 100644 --- a/apps/app/src/lib/i18n/dictionaries/es.json +++ b/apps/app/src/lib/i18n/dictionaries/es.json @@ -227,6 +227,10 @@ "Change to Member": "Cambiar a Miembro", "Change to Admin": "Cambiar a Administrador", "Remove from organization": "Eliminar de la organización", + "Remove from process": "Eliminar del proceso", + "User removed from process": "Usuario eliminado del proceso", + "Invite removed from process": "Invitación eliminada del proceso", + "Failed to remove invite": "Error al eliminar la invitación", "Member": "Miembro", "User changed to Admin successfully": "Usuario cambiado a Administrador exitosamente", "User changed to Member successfully": "Usuario cambiado a Miembro exitosamente", @@ -576,6 +580,8 @@ "Selected members": "Miembros seleccionados", "No email addresses to invite": "No hay direcciones de correo para invitar", "Remove {name}": "Eliminar {name}", + "Are you sure you want to remove {name} from \"{processName}\"?": "¿Estás seguro de que quieres eliminar a {name} de \"{processName}\"?", + "Are you sure you want to remove {name} from this process?": "¿Estás seguro de que quieres eliminar a {name} de este proceso?", "Select a role": "Seleccionar un rol", "Add role": "Agregar rol", "Admin": "Admin", diff --git a/apps/app/src/lib/i18n/dictionaries/fr.json b/apps/app/src/lib/i18n/dictionaries/fr.json index a3d5f3b7f..bac51d4bd 100644 --- a/apps/app/src/lib/i18n/dictionaries/fr.json +++ b/apps/app/src/lib/i18n/dictionaries/fr.json @@ -228,6 +228,10 @@ "Change to Member": "Changer en Membre", "Change to Admin": "Changer en Administrateur", "Remove from organization": "Supprimer de l'organisation", + "Remove from process": "Retirer du processus", + "User removed from process": "Utilisateur retiré du processus", + "Invite removed from process": "Invitation retirée du processus", + "Failed to remove invite": "Échec de la suppression de l'invitation", "Member": "Membre", "User changed to Admin successfully": "Utilisateur changé en Administrateur avec succès", "User changed to Member successfully": "Utilisateur changé en Membre avec succès", @@ -577,6 +581,8 @@ "Selected members": "Membres sélectionnés", "No email addresses to invite": "Aucune adresse courriel à inviter", "Remove {name}": "Supprimer {name}", + "Are you sure you want to remove {name} from \"{processName}\"?": "Êtes-vous sûr de vouloir retirer {name} de \"{processName}\" ?", + "Are you sure you want to remove {name} from this process?": "Êtes-vous sûr de vouloir retirer {name} de ce processus ?", "Select a role": "Sélectionner un rôle", "Add role": "Ajouter un rôle", "Admin": "Admin", diff --git a/apps/app/src/lib/i18n/dictionaries/pt.json b/apps/app/src/lib/i18n/dictionaries/pt.json index 04059e535..17eecaac3 100644 --- a/apps/app/src/lib/i18n/dictionaries/pt.json +++ b/apps/app/src/lib/i18n/dictionaries/pt.json @@ -228,6 +228,10 @@ "Change to Member": "Mudar para Membro", "Change to Admin": "Mudar para Administrador", "Remove from organization": "Remover da organização", + "Remove from process": "Remover do processo", + "User removed from process": "Usuário removido do processo", + "Invite removed from process": "Convite removido do processo", + "Failed to remove invite": "Falha ao remover convite", "Member": "Membro", "User changed to Admin successfully": "Usuário alterado para Administrador com sucesso", "User changed to Member successfully": "Usuário alterado para Membro com sucesso", @@ -577,6 +581,8 @@ "Selected members": "Membros selecionados", "No email addresses to invite": "Nenhum endereço de e-mail para convidar", "Remove {name}": "Remover {name}", + "Are you sure you want to remove {name} from \"{processName}\"?": "Tem certeza de que deseja remover {name} de \"{processName}\"?", + "Are you sure you want to remove {name} from this process?": "Tem certeza de que deseja remover {name} deste processo?", "Select a role": "Selecionar um cargo", "Add role": "Adicionar função", "Admin": "Admin", diff --git a/package.json b/package.json index 2df124f89..e4f96aa75 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,6 @@ "immer": "^10.1.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", - "lucide-react": "^0.476.0", "motion": "12.4.7", "nanoid": "5.1.2", "next": "catalog:", @@ -148,7 +147,6 @@ "immer": "$immer", "js-yaml": "$js-yaml", "lodash": "$lodash", - "lucide-react": "$lucide-react", "motion": "$motion", "postcss": "$postcss", "postgres": "$postgres", diff --git a/packages/common/src/services/access/permissions.ts b/packages/common/src/services/access/permissions.ts index ef58f8483..7083aa0fb 100644 --- a/packages/common/src/services/access/permissions.ts +++ b/packages/common/src/services/access/permissions.ts @@ -1,15 +1,39 @@ +import { invalidateMultiple } from '@op/cache'; import { db } from '@op/db/client'; import { accessRolePermissionsOnAccessZones, accessRoles, organizationUserToAccessRoles, + profileUserToAccessRoles, + profileUsers, } from '@op/db/schema'; -import { toBitField } from 'access-zones'; +import { permission, toBitField } from 'access-zones'; import { and, eq } from 'drizzle-orm'; import { CommonError, NotFoundError } from '../../utils'; import { assertProfileAdmin } from '../assert'; +export async function invalidateProfileUserCacheForRole(roleId: string) { + const affectedUsers = await db + .select({ + profileId: profileUsers.profileId, + authUserId: profileUsers.authUserId, + }) + .from(profileUserToAccessRoles) + .innerJoin( + profileUsers, + eq(profileUserToAccessRoles.profileUserId, profileUsers.id), + ) + .where(eq(profileUserToAccessRoles.accessRoleId, roleId)); + + if (affectedUsers.length > 0) { + await invalidateMultiple({ + type: 'profileUser', + paramsList: affectedUsers.map((u) => [u.profileId, u.authUserId]), + }); + } +} + export type Permissions = { admin: boolean; create: boolean; @@ -66,6 +90,27 @@ export async function createRole({ permission: toBitField(permissions), }); + // Scoped roles always get profile READ + if (zoneName !== 'profile') { + const profileZone = await tx.query.accessZones.findFirst({ + where: { name: 'profile' }, + }); + + if (profileZone) { + await tx.insert(accessRolePermissionsOnAccessZones).values({ + accessRoleId: role.id, + accessZoneId: profileZone.id, + permission: permission.READ, + }); + } + } else if (!permissions.read) { + // If creating on the profile zone but read wasn't set, force it + await tx + .update(accessRolePermissionsOnAccessZones) + .set({ permission: toBitField({ ...permissions, read: true }) }) + .where(eq(accessRolePermissionsOnAccessZones.accessRoleId, role.id)); + } + return { id: role.id, name: role.name, @@ -131,6 +176,8 @@ export async function updateRolePermissions({ }); } + await invalidateProfileUserCacheForRole(roleId); + return role; } @@ -159,6 +206,9 @@ export async function deleteRole({ await assertProfileAdmin(user, role.profileId); + // Invalidate before delete (cascade will remove the join rows we query) + await invalidateProfileUserCacheForRole(roleId); + // Delete the role (cascade will handle permissions) await db.delete(accessRoles).where(eq(accessRoles.id, roleId)); diff --git a/packages/common/src/services/decision/decisionRoles.ts b/packages/common/src/services/decision/decisionRoles.ts index dce2fe826..a4e93edc1 100644 --- a/packages/common/src/services/decision/decisionRoles.ts +++ b/packages/common/src/services/decision/decisionRoles.ts @@ -4,6 +4,7 @@ import { permission, toBitField } from 'access-zones'; import { eq } from 'drizzle-orm'; import { CommonError, NotFoundError } from '../../utils'; +import { invalidateProfileUserCacheForRole } from '../access/permissions'; import { assertProfileAdmin } from '../assert'; import { type DecisionRolePermissions, @@ -51,6 +52,31 @@ export async function createDecisionRole({ }) { const client = tx ?? db; + // Scoped roles on a decision process always get profile READ + if (permissions['profile']) { + const existing = permissions['profile']; + if (existing.type === 'acrud') { + permissions = { + ...permissions, + profile: { type: 'acrud', value: { ...existing.value, read: true } }, + }; + } + } else { + permissions = { + ...permissions, + profile: { + type: 'acrud', + value: { + admin: false, + create: false, + read: true, + update: false, + delete: false, + }, + }, + }; + } + const zoneNames = Object.keys(permissions); const zones = await Promise.all( zoneNames.map((zoneName) => @@ -166,5 +192,7 @@ export async function updateDecisionRoles({ }); } + await invalidateProfileUserCacheForRole(roleId); + return { roleId, decisionPermissions }; } diff --git a/packages/common/src/services/profile/updateProfileUserRole.ts b/packages/common/src/services/profile/updateProfileUserRole.ts index e487d234b..b41308ce0 100644 --- a/packages/common/src/services/profile/updateProfileUserRole.ts +++ b/packages/common/src/services/profile/updateProfileUserRole.ts @@ -1,3 +1,4 @@ +import { invalidate } from '@op/cache'; import { and, db, eq, inArray } from '@op/db/client'; import { profileUserToAccessRoles } from '@op/db/schema'; import type { User } from '@op/supabase/lib'; @@ -102,6 +103,11 @@ export const updateProfileUserRoles = async ({ }); } + await invalidate({ + type: 'profileUser', + params: [targetProfileId, targetProfileUser.authUserId], + }); + // Fetch and return the updated profile user with full relations const updatedProfileUser = await getProfileUserWithRelations(profileUserId); if (!updatedProfileUser) { diff --git a/packages/ui/package.json b/packages/ui/package.json index 094f7e75f..9129d7824 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -72,6 +72,7 @@ "./utils/formatting": "./src/utils/formatting.ts", "./lib/primitive": "./src/lib/primitive.ts", "./hooks/use-media-query": "./src/hooks/use-media-query.ts", + "./AlertBanner": "./src/components/AlertBanner.tsx", "./ui/table": "./src/components/ui/table.tsx" }, "module": "./src/index.ts", @@ -108,7 +109,6 @@ "cmdk": "^1.0.4", "framer-motion": "^12.7.4", "lodash": "^4.17.21", - "lucide-react": "^0.476.0", "react": "^19.0.1", "react-aria": "^3.37.0", "react-aria-components": "^1.6.0", diff --git a/packages/ui/src/components/AlertBanner.tsx b/packages/ui/src/components/AlertBanner.tsx new file mode 100644 index 000000000..b781e8611 --- /dev/null +++ b/packages/ui/src/components/AlertBanner.tsx @@ -0,0 +1,140 @@ +import { ReactNode } from 'react'; +import { LuCircleAlert, LuCircleCheck, LuInfo } from 'react-icons/lu'; +import { tv } from 'tailwind-variants'; + +import { cn } from '../lib/utils'; + +const alertBannerStyles = tv({ + slots: { + root: 'w-full overflow-hidden rounded-lg border p-4 *:[a]:hover:underline **:[strong]:font-medium', + indicatorOuter: + 'me-3 grid size-8 place-content-center rounded-full border-2', + indicatorInner: 'grid size-6 place-content-center rounded-full border-2', + content: 'text-pretty', + }, + variants: { + intent: { + default: { + root: 'bg-muted/50 text-secondary-fg', + }, + info: { + root: 'bg-info-subtle text-info-subtle-fg **:[.text-muted-fg]:text-info-subtle-fg/70', + indicatorOuter: 'border-info-subtle-fg/40', + indicatorInner: 'border-info-subtle-fg/85', + }, + warning: { + root: 'bg-warning-subtle text-warning-subtle-fg **:[.text-muted-fg]:text-warning-subtle-fg/80', + indicatorOuter: 'border-warning-subtle-fg/40', + indicatorInner: 'border-warning-subtle-fg/85', + }, + danger: { + root: 'bg-danger-subtle text-danger-subtle-fg **:[.text-muted-fg]:text-danger-subtle-fg/80', + indicatorOuter: 'border-danger-subtle-fg/40', + indicatorInner: 'border-danger-subtle-fg/85', + }, + success: { + root: 'bg-success-subtle text-success-subtle-fg **:[.text-muted-fg]:text-success-subtle-fg/80', + indicatorOuter: 'border-success-subtle-fg/40', + indicatorInner: 'border-success-subtle-fg/85', + }, + }, + variant: { + default: { + root: 'grid grid-cols-[auto_1fr] text-base/6 backdrop-blur-2xl sm:text-sm/6', + content: 'group-has-data-[slot=icon]:col-start-2', + }, + banner: { + root: 'flex items-center gap-1 shadow-light', + content: 'flex min-w-0 items-center gap-1', + }, + }, + }, + compoundVariants: [ + { + variant: 'banner', + intent: 'warning', + class: { + root: 'border-primary-orange1 text-neutral-black [background:linear-gradient(rgba(255,255,255,0.92),rgba(255,255,255,0.92)),var(--color-primary-orange1)]', + }, + }, + { + variant: 'banner', + intent: 'danger', + class: { + root: 'border-functional-red text-neutral-black [background:linear-gradient(rgba(255,255,255,0.96),rgba(255,255,255,0.96)),var(--color-functional-red)]', + }, + }, + { + variant: 'banner', + intent: 'default', + class: { + root: 'border-neutral-gray2 bg-neutral-offWhite text-neutral-black', + }, + }, + ], + defaultVariants: { + intent: 'default', + variant: 'default', + }, +}); + +const iconMap: Record< + string, + React.ComponentType<{ className?: string }> | null +> = { + info: LuInfo, + warning: LuCircleAlert, + danger: LuCircleAlert, + success: LuCircleCheck, + default: null, +}; + +export interface AlertBannerProps + extends React.HtmlHTMLAttributes { + intent?: 'default' | 'info' | 'warning' | 'danger' | 'success'; + variant?: 'default' | 'banner'; + indicator?: boolean; + icon?: ReactNode; + contentClassName?: string; +} + +export function AlertBanner({ + indicator = true, + intent = 'default', + variant = 'default', + icon, + className, + contentClassName, + ...props +}: AlertBannerProps) { + const styles = alertBannerStyles({ intent, variant }); + const IconComponent = iconMap[intent] || null; + + return ( +
+ {variant === 'banner' ? ( + <> + + {icon ?? } + + + {props.children} + + + ) : ( + <> + {IconComponent && indicator && ( +
+
+ +
+
+ )} +
+ {props.children} +
+ + )} +
+ ); +} diff --git a/packages/ui/src/components/AvatarUploader.tsx b/packages/ui/src/components/AvatarUploader.tsx index eed725373..7f5aec631 100644 --- a/packages/ui/src/components/AvatarUploader.tsx +++ b/packages/ui/src/components/AvatarUploader.tsx @@ -1,6 +1,6 @@ -import { Camera } from 'lucide-react'; import { useRef } from 'react'; import { useButton } from 'react-aria'; +import { LuCamera } from 'react-icons/lu'; import { cn } from '../lib/utils'; import { LoadingSpinner } from './LoadingSpinner'; @@ -66,7 +66,7 @@ export const AvatarUploader = ({ {uploading ? ( ) : ( - + )}
diff --git a/packages/ui/src/components/BannerUploader.tsx b/packages/ui/src/components/BannerUploader.tsx index 2aa20b8ef..4f0918fe2 100644 --- a/packages/ui/src/components/BannerUploader.tsx +++ b/packages/ui/src/components/BannerUploader.tsx @@ -1,6 +1,6 @@ -import { Camera } from 'lucide-react'; import { useRef } from 'react'; import { useButton } from 'react-aria'; +import { LuCamera } from 'react-icons/lu'; import { cn } from '../lib/utils'; import { LoadingSpinner } from './LoadingSpinner'; @@ -60,7 +60,7 @@ export const BannerUploader = ({ {uploading ? ( ) : ( - + )}
diff --git a/packages/ui/src/components/Breadcrumbs.tsx b/packages/ui/src/components/Breadcrumbs.tsx index f42cb4610..b06760f6c 100644 --- a/packages/ui/src/components/Breadcrumbs.tsx +++ b/packages/ui/src/components/Breadcrumbs.tsx @@ -1,6 +1,5 @@ 'use client'; -import { ChevronRight } from 'lucide-react'; import { Breadcrumb as AriaBreadcrumb, Breadcrumbs as AriaBreadcrumbs, @@ -10,6 +9,7 @@ import type { BreadcrumbsProps, LinkProps, } from 'react-aria-components'; +import { LuChevronRight } from 'react-icons/lu'; import { twMerge } from 'tailwind-merge'; import { composeTailwindRenderProps } from '../utils'; @@ -36,7 +36,7 @@ export const Breadcrumb = ( )} > - {props.href && } + {props.href && } ); }; diff --git a/packages/ui/src/components/Calendar.tsx b/packages/ui/src/components/Calendar.tsx index 620877501..0d2709381 100644 --- a/packages/ui/src/components/Calendar.tsx +++ b/packages/ui/src/components/Calendar.tsx @@ -1,7 +1,6 @@ 'use client'; import { getLocalTimeZone, isToday } from '@internationalized/date'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Calendar as AriaCalendar, CalendarGridHeader as AriaCalendarGridHeader, @@ -17,6 +16,7 @@ import type { CalendarProps as AriaCalendarProps, DateValue, } from 'react-aria-components'; +import { LuChevronLeft, LuChevronRight } from 'react-icons/lu'; import { tv } from 'tailwind-variants'; import { focusRing } from '../utils'; @@ -55,9 +55,9 @@ export const CalendarHeader = () => { className="h-8 w-8 rounded-none bg-white p-0 text-neutral-charcoal shadow-none hover:bg-neutral-offWhite pressed:bg-neutral-offWhite pressed:shadow-none" > {direction === 'rtl' ? ( - + ) : ( - + )} @@ -67,9 +67,9 @@ export const CalendarHeader = () => { className="h-8 w-8 rounded-none bg-white p-0 text-neutral-charcoal shadow-none hover:bg-neutral-offWhite pressed:bg-neutral-offWhite pressed:shadow-none" > {direction === 'rtl' ? ( - + ) : ( - + )} diff --git a/packages/ui/src/components/Checkbox.tsx b/packages/ui/src/components/Checkbox.tsx index adda07a7b..89ad06e2b 100644 --- a/packages/ui/src/components/Checkbox.tsx +++ b/packages/ui/src/components/Checkbox.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Check, Minus } from 'lucide-react'; import type { ReactNode } from 'react'; import { Checkbox as AriaCheckbox, @@ -12,6 +11,7 @@ import type { CheckboxProps, ValidationResult, } from 'react-aria-components'; +import { LuCheck, LuMinus } from 'react-icons/lu'; import { VariantProps, tv } from 'tailwind-variants'; import { composeTailwindRenderProps, focusRing } from '../utils'; @@ -124,9 +124,15 @@ export const Checkbox = (props: CheckboxProps & CheckboxVariants) => { })} > {isIndeterminate ? ( - + ) : isSelected ? ( - + ) : null}
{props.children} diff --git a/packages/ui/src/components/ComboBox.tsx b/packages/ui/src/components/ComboBox.tsx index 38e149339..74e86eb0b 100644 --- a/packages/ui/src/components/ComboBox.tsx +++ b/packages/ui/src/components/ComboBox.tsx @@ -1,12 +1,12 @@ 'use client'; -import { ChevronDown } from 'lucide-react'; import { ComboBox as AriaComboBox, ListBox } from 'react-aria-components'; import type { ComboBoxProps as AriaComboBoxProps, ListBoxItemProps, ValidationResult, } from 'react-aria-components'; +import { LuChevronDown } from 'react-icons/lu'; import { cn } from '../lib/utils'; import { composeTailwindRenderProps } from '../utils'; @@ -60,7 +60,7 @@ export const ComboBox = ({ props.buttonProps?.className, )} > - + {description && {description}} diff --git a/packages/ui/src/components/CommentButton.tsx b/packages/ui/src/components/CommentButton.tsx index 64f87e8fb..6ac3d806f 100644 --- a/packages/ui/src/components/CommentButton.tsx +++ b/packages/ui/src/components/CommentButton.tsx @@ -24,9 +24,10 @@ const MessageCircleIcon = ({ className }: { className?: string }) => ( diff --git a/packages/ui/src/components/DatePicker.tsx b/packages/ui/src/components/DatePicker.tsx index b3735c24d..fbe24f8a4 100644 --- a/packages/ui/src/components/DatePicker.tsx +++ b/packages/ui/src/components/DatePicker.tsx @@ -1,10 +1,10 @@ 'use client'; import { parseDate } from '@internationalized/date'; -import { CalendarIcon } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { DateValue } from 'react-aria-components'; import { Button as AriaButton, DialogTrigger } from 'react-aria-components'; +import { LuCalendar } from 'react-icons/lu'; import { cn } from '../lib/utils'; import { Calendar } from './Calendar'; @@ -210,7 +210,7 @@ export const DatePicker = ({ props.isDisabled && 'cursor-not-allowed text-lightGray', )} > - + diff --git a/packages/ui/src/components/FileDropZone.tsx b/packages/ui/src/components/FileDropZone.tsx index 0753808a6..db039bb17 100644 --- a/packages/ui/src/components/FileDropZone.tsx +++ b/packages/ui/src/components/FileDropZone.tsx @@ -45,8 +45,8 @@ const dropZoneStyles = tv({ root: 'group/dropzone flex w-full', button: [ 'flex flex-1 cursor-pointer flex-col items-center justify-center gap-6', - 'rounded-2xl border border-dashed border-neutral-gray2 bg-neutral-offWhite', - 'px-12 py-8', + 'rounded-lg border border-dashed border-neutral-gray2 bg-neutral-offWhite', + 'px-12 py-6', 'outline-hidden transition-colors duration-200', 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500', 'group-data-[drop-target]/dropzone:border-teal-500 group-data-[drop-target]/dropzone:bg-teal-50/30', diff --git a/packages/ui/src/components/Header.tsx b/packages/ui/src/components/Header.tsx index af16d95de..88d72b311 100644 --- a/packages/ui/src/components/Header.tsx +++ b/packages/ui/src/components/Header.tsx @@ -42,6 +42,22 @@ export const Header3 = ({ ); }; +export const Header4 = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) => { + return ( +

+ {children} +

+ ); +}; + export const GradientHeader = ({ children, className, diff --git a/packages/ui/src/components/ListBox.tsx b/packages/ui/src/components/ListBox.tsx index d5f23e843..8a3875729 100644 --- a/packages/ui/src/components/ListBox.tsx +++ b/packages/ui/src/components/ListBox.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Check } from 'lucide-react'; import { ListBox as AriaListBox, ListBoxItem as AriaListBoxItem, @@ -14,6 +13,7 @@ import type { ListBoxItemProps as RACListBoxItemProps, SectionProps, } from 'react-aria-components'; +import { LuCheck } from 'react-icons/lu'; import { tv } from 'tailwind-variants'; import type { VariantProps } from 'tailwind-variants'; @@ -139,7 +139,7 @@ export const DropdownItem = ( {children}
- {isSelected && } + {isSelected && } ))} diff --git a/packages/ui/src/components/Menu.tsx b/packages/ui/src/components/Menu.tsx index cc1e117f3..d09d972b1 100644 --- a/packages/ui/src/components/Menu.tsx +++ b/packages/ui/src/components/Menu.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Check, ChevronRight } from 'lucide-react'; import { Menu as AriaMenu, MenuItem as AriaMenuItem, @@ -13,6 +12,7 @@ import type { MenuItemProps, SeparatorProps, } from 'react-aria-components'; +import { LuCheck, LuChevronRight } from 'react-icons/lu'; import { VariantProps, cn, tv } from '../lib/utils'; import { DropdownSection, dropdownItemStyles } from './ListBox'; @@ -89,14 +89,14 @@ export const MenuItem = ( <> {selectionMode !== 'none' && ( - {isSelected && } + {isSelected && } )} {children} {hasSubmenu && ( - + )} ), diff --git a/packages/ui/src/components/Pagination.tsx b/packages/ui/src/components/Pagination.tsx index 746087815..7f639da62 100644 --- a/packages/ui/src/components/Pagination.tsx +++ b/packages/ui/src/components/Pagination.tsx @@ -1,9 +1,9 @@ 'use client'; import clsx from 'clsx'; -import { ChevronLeft, ChevronRight } from 'lucide-react'; import * as React from 'react'; import { type ContextValue, useContextProps } from 'react-aria-components'; +import { LuChevronLeft, LuChevronRight } from 'react-icons/lu'; import { Button } from './Button'; @@ -85,7 +85,7 @@ const PaginationNavigation = React.forwardRef< } }} > - + Previous ); diff --git a/packages/ui/src/components/PhaseStepper.tsx b/packages/ui/src/components/PhaseStepper.tsx index fd46a35ca..c9d6013df 100644 --- a/packages/ui/src/components/PhaseStepper.tsx +++ b/packages/ui/src/components/PhaseStepper.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Check } from 'lucide-react'; +import { LuCheck } from 'react-icons/lu'; import { cn } from '../lib/utils'; import { formatDateRange } from '../utils/formatting'; @@ -42,7 +42,7 @@ const Step = ({ 'border border-neutral-charcoal bg-transparent text-neutral-charcoal', )} > - {stepState === 'completed' ? : index + 1} + {stepState === 'completed' ? : index + 1}
{phase.name}
diff --git a/packages/ui/src/components/ReactionsButton.tsx b/packages/ui/src/components/ReactionsButton.tsx index d42e50fec..e9723f235 100644 --- a/packages/ui/src/components/ReactionsButton.tsx +++ b/packages/ui/src/components/ReactionsButton.tsx @@ -1,7 +1,7 @@ 'use client'; -import { SmilePlus } from 'lucide-react'; import { Button as RACButton } from 'react-aria-components'; +import { LuSmilePlus } from 'react-icons/lu'; import { tv } from 'tailwind-variants'; import type { VariantProps } from 'tailwind-variants'; @@ -88,7 +88,7 @@ export const ReactionButton = ({ {...props} className={reactionButtonStyle({ size, active, className })} > - + ); } diff --git a/packages/ui/src/components/SearchField.tsx b/packages/ui/src/components/SearchField.tsx index 5473a7ca9..a1f9c1c76 100644 --- a/packages/ui/src/components/SearchField.tsx +++ b/packages/ui/src/components/SearchField.tsx @@ -1,11 +1,11 @@ 'use client'; -import { SearchIcon, XIcon } from 'lucide-react'; import { SearchField as AriaSearchField } from 'react-aria-components'; import type { SearchFieldProps as AriaSearchFieldProps, ValidationResult, } from 'react-aria-components'; +import { LuSearch, LuX } from 'react-icons/lu'; import { composeTailwindRenderProps } from '../utils'; import { Button } from './Button'; @@ -37,7 +37,7 @@ export const SearchField = ({ > {label && } - @@ -51,7 +51,7 @@ export const SearchField = ({ color="ghost" className="absolute top-1/2 right-1 aspect-square w-6 -translate-y-1/2 p-0 group-empty:invisible" > - + {description && {description}} diff --git a/packages/ui/src/components/Select.tsx b/packages/ui/src/components/Select.tsx index 6266289e0..a7ce328d4 100644 --- a/packages/ui/src/components/Select.tsx +++ b/packages/ui/src/components/Select.tsx @@ -1,6 +1,5 @@ 'use client'; -import { ChevronDown } from 'lucide-react'; import { ReactNode } from 'react'; import { Select as AriaSelect, @@ -13,6 +12,7 @@ import type { ListBoxItemProps, ValidationResult, } from 'react-aria-components'; +import { LuChevronDown } from 'react-icons/lu'; import { VariantProps, tv } from 'tailwind-variants'; import { cn } from '../lib/utils'; @@ -125,7 +125,10 @@ export const Select = ({ )} /> {icon ?? ( - + )} diff --git a/packages/ui/src/components/TagGroup.tsx b/packages/ui/src/components/TagGroup.tsx index 5a6572055..d526b6b60 100644 --- a/packages/ui/src/components/TagGroup.tsx +++ b/packages/ui/src/components/TagGroup.tsx @@ -1,6 +1,5 @@ 'use client'; -import { XIcon } from 'lucide-react'; import { createContext, useContext } from 'react'; import { Tag as AriaTag, @@ -15,6 +14,7 @@ import type { TagProps as AriaTagProps, TagListProps, } from 'react-aria-components'; +import { LuX } from 'react-icons/lu'; import { twMerge } from 'tailwind-merge'; import { tv } from 'tailwind-variants'; @@ -136,7 +136,7 @@ export const Tag = ({ children, color, ...props }: TagProps) => { {children} {allowsRemoving && ( )} diff --git a/packages/ui/src/components/TextField.tsx b/packages/ui/src/components/TextField.tsx index 0741a952b..7e97a0aae 100644 --- a/packages/ui/src/components/TextField.tsx +++ b/packages/ui/src/components/TextField.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import { TextField as AriaTextField } from 'react-aria-components'; import type { TextFieldProps as AriaTextFieldProps, @@ -30,6 +31,7 @@ export interface TextFieldProps extends AriaTextFieldProps { labelClassName?: string; errorClassName?: string; useTextArea?: boolean; + maxLength?: number; } export const TextField = ({ @@ -44,6 +46,7 @@ export const TextField = ({ labelClassName, errorClassName, useTextArea, + maxLength, children, isRequired, // we pull this out as it conflicts with other form validation libraries ...props @@ -51,10 +54,41 @@ export const TextField = ({ ref?: React.RefObject; children?: React.ReactNode; }) => { + const isControlled = props.value !== undefined; + const [uncontrolledCount, setUncontrolledCount] = useState( + () => (props.defaultValue ?? '').length, + ); + const charCount = isControlled + ? (props.value?.length ?? 0) + : uncontrolledCount; + + const isInvalid = !!errorMessage && errorMessage.length > 0; + + const handleChange = (value: string) => { + if (!isControlled) { + setUncontrolledCount(value.length); + } + props.onChange?.(value); + }; + + const counterElement = maxLength != null && ( + + {charCount}/{maxLength} + + ); + return ( 0} + onChange={handleChange} + maxLength={maxLength} + isInvalid={isInvalid} className={composeTailwindRenderProps( props.className, 'group flex flex-col gap-1', @@ -94,12 +128,24 @@ export const TextField = ({ {children} - {description && ( - - {description} - + {description ? ( +
+ + {description} + + {counterElement} +
+ ) : ( + counterElement && ( +
+ {errorMessage} + {counterElement} +
+ ) + )} + {(description || !counterElement) && ( + {errorMessage} )} - {errorMessage}
); }; diff --git a/packages/ui/src/components/TranslateBanner.tsx b/packages/ui/src/components/TranslateBanner.tsx index 774f241ab..086bd7673 100644 --- a/packages/ui/src/components/TranslateBanner.tsx +++ b/packages/ui/src/components/TranslateBanner.tsx @@ -1,4 +1,4 @@ -import { Languages, X } from 'lucide-react'; +import { LuLanguages, LuX } from 'react-icons/lu'; import { cn } from '../lib/utils'; import { Button } from './Button'; @@ -45,7 +45,7 @@ export const TranslateBanner = ({ className="group flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-full text-left text-primary-teal outline-hidden transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-data-blue disabled:cursor-not-allowed disabled:opacity-60" > - + {label} @@ -56,7 +56,7 @@ export const TranslateBanner = ({ unstyled className="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-neutral-gray4 outline-hidden transition-colors hover:bg-neutral-gray1 hover:text-neutral-charcoal focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-data-blue" > - +
); diff --git a/packages/ui/src/components/icons/CheckIcon.tsx b/packages/ui/src/components/icons/CheckIcon.tsx index 7d037547d..eb120cad6 100644 --- a/packages/ui/src/components/icons/CheckIcon.tsx +++ b/packages/ui/src/components/icons/CheckIcon.tsx @@ -14,9 +14,10 @@ export const CheckIcon = ({ className }: { className?: string }) => ( ); diff --git a/packages/ui/stories/AlertBanner.stories.tsx b/packages/ui/stories/AlertBanner.stories.tsx new file mode 100644 index 000000000..cb5c080c2 --- /dev/null +++ b/packages/ui/stories/AlertBanner.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LuTriangleAlert } from 'react-icons/lu'; + +import { AlertBanner } from '../src/components/AlertBanner'; + +const meta: Meta = { + title: 'AlertBanner', + component: AlertBanner, + tags: ['autodocs'], + args: { + variant: 'banner', + }, + argTypes: { + intent: { + control: 'select', + options: ['default', 'info', 'warning', 'danger', 'success'], + }, + variant: { + control: 'select', + options: ['default', 'banner'], + }, + children: { + control: 'text', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Warning: Story = { + args: { + intent: 'warning', + children: 'This action requires your attention before proceeding.', + }, +}; + +export const Alert: Story = { + args: { + intent: 'danger', + children: 'There was a critical error processing your request.', + }, +}; + +export const Neutral: Story = { + args: { + intent: 'default', + children: 'Your session will expire in 5 minutes.', + }, +}; + +export const CustomIcon: Story = { + args: { + intent: 'warning', + icon: , + children: 'Warning with a custom triangle icon.', + }, +}; + +export const LongText: Story = { + args: { + intent: 'warning', + children: + 'This is a very long message that should be truncated with an ellipsis when it overflows the container width. It keeps going and going to demonstrate the text-overflow behavior of the AlertBanner component.', + }, +}; + +export const BannerVariants = () => ( +
+ + Warning: This action requires your attention. + + + Alert: There was a critical error processing your request. + + + Info: Your session will expire in 5 minutes. + +
+); + +export const DefaultVariant: Story = { + args: { + variant: 'default', + intent: 'warning', + children: 'This uses the default AlertBanner styling with indicator.', + }, +}; diff --git a/packages/ui/stories/AllComponents.stories.tsx b/packages/ui/stories/AllComponents.stories.tsx index 26287687d..c83731532 100644 --- a/packages/ui/stories/AllComponents.stories.tsx +++ b/packages/ui/stories/AllComponents.stories.tsx @@ -1,11 +1,5 @@ // @ts-nocheck // TODO: commenting for a demo -// import { -// BoldIcon, -// ItalicIcon, -// MoreHorizontal, -// UnderlineIcon, -// } from 'lucide-react'; // import { DialogTrigger, Group, MenuTrigger } from 'react-aria-components'; // import { TimeField } from '../src/components/TimeField'; // import { ToggleButton } from '../src/components/ToggleButton'; diff --git a/packages/ui/stories/Menu.stories.tsx b/packages/ui/stories/Menu.stories.tsx index 9ed9ecf6a..0b79e7b43 100644 --- a/packages/ui/stories/Menu.stories.tsx +++ b/packages/ui/stories/Menu.stories.tsx @@ -1,6 +1,6 @@ import type { Meta } from '@storybook/react-vite'; -import { MoreHorizontal } from 'lucide-react'; import { MenuTrigger, SubmenuTrigger } from 'react-aria-components'; +import { LuEllipsis } from 'react-icons/lu'; import { Button } from '../src/components/Button'; import { @@ -23,7 +23,7 @@ export default meta; export const Example = (args: any) => ( New… @@ -45,7 +45,7 @@ DisabledItems.args = { export const Sections = (args: any) => ( @@ -67,7 +67,7 @@ export const Sections = (args: any) => ( export const Submenu = (args: any) => ( New… diff --git a/packages/ui/stories/Popover.stories.tsx b/packages/ui/stories/Popover.stories.tsx index de22d1230..102712ce8 100644 --- a/packages/ui/stories/Popover.stories.tsx +++ b/packages/ui/stories/Popover.stories.tsx @@ -1,6 +1,6 @@ import type { Meta } from '@storybook/react-vite'; -import { HelpCircle } from 'lucide-react'; import { DialogTrigger, Heading } from 'react-aria-components'; +import { LuCircleHelp } from 'react-icons/lu'; import { Button } from '../src/components/Button'; import { Dialog } from '../src/components/Dialog'; @@ -22,7 +22,7 @@ export default meta; export const Example = (args: any) => ( diff --git a/packages/ui/stories/TextField.stories.tsx b/packages/ui/stories/TextField.stories.tsx index 0a7478293..042e921d0 100644 --- a/packages/ui/stories/TextField.stories.tsx +++ b/packages/ui/stories/TextField.stories.tsx @@ -60,3 +60,41 @@ export const Validation = (args: any) => ( Validation.args = { isRequired: true, }; + +export const WithCharacterLimit = () => ( +
+ + + + + +
+); diff --git a/packages/ui/stories/Tooltip.stories.tsx b/packages/ui/stories/Tooltip.stories.tsx index fea77f11b..8f0cdda83 100644 --- a/packages/ui/stories/Tooltip.stories.tsx +++ b/packages/ui/stories/Tooltip.stories.tsx @@ -1,6 +1,6 @@ import type { Meta } from '@storybook/react-vite'; -import { PrinterIcon, SaveIcon } from 'lucide-react'; import { TooltipTrigger } from 'react-aria-components'; +import { LuPrinter, LuSave } from 'react-icons/lu'; import { Button } from '../src/components/Button'; import { Tooltip } from '../src/components/Tooltip'; @@ -19,13 +19,13 @@ export const Example = (args: any) => (
Save Print diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5fdd7aa9..03978965d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,7 +128,6 @@ overrides: immer: ^10.1.1 js-yaml: ^4.1.0 lodash: ^4.17.21 - lucide-react: ^0.476.0 motion: 12.4.7 postcss: ^8.5.3 postgres: ^3.4.5 @@ -280,9 +279,6 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 - lucide-react: - specifier: ^0.476.0 - version: 0.476.0(react@19.2.1) motion: specifier: 12.4.7 version: 12.4.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -1067,9 +1063,6 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 - lucide-react: - specifier: ^0.476.0 - version: 0.476.0(react@19.2.1) react: specifier: ^19.0.1 version: 19.2.1 @@ -7637,11 +7630,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.476.0: - resolution: {integrity: sha512-x6cLTk8gahdUPje0hSgLN1/MgiJH+Xl90Xoxy9bkPAsMPOUiyRSKR4JCDPGVCEpyqnZXH3exFWNItcvra9WzUQ==} - peerDependencies: - react: ^19.0.1 - lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -16552,10 +16540,6 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.476.0(react@19.2.1): - dependencies: - react: 19.2.1 - lz-string@1.5.0: {} maath@0.10.8(@types/three@0.174.0)(three@0.174.0): diff --git a/services/api/src/routers/decision/proposals/submit.ts b/services/api/src/routers/decision/proposals/submit.ts index a43679a74..57801f9cd 100644 --- a/services/api/src/routers/decision/proposals/submit.ts +++ b/services/api/src/routers/decision/proposals/submit.ts @@ -1,4 +1,5 @@ import { submitProposal } from '@op/common'; +import { Events, inngest } from '@op/events'; import { waitUntil } from '@vercel/functions'; import { z } from 'zod'; @@ -29,6 +30,14 @@ export const submitProposalRouter = router({ }), ); + // Send proposal submitted event for notification workflow + waitUntil( + inngest.send({ + name: Events.proposalSubmitted.name, + data: { proposalId: proposal.id }, + }), + ); + return proposalEncoder.parse(proposal); }), }); diff --git a/services/api/src/test/setup.ts b/services/api/src/test/setup.ts index 2abe600f6..0976d6588 100644 --- a/services/api/src/test/setup.ts +++ b/services/api/src/test/setup.ts @@ -60,10 +60,14 @@ const mockPlatformAdminEmails = { // Mock the event system to avoid Inngest API calls in tests vi.mock('@op/events', async () => { const actual = await vi.importActual('@op/events'); + const mockSend = vi.fn().mockResolvedValue({ ids: ['mock-event-id'] }); return { ...actual, + inngest: { + send: mockSend, + }, event: { - send: vi.fn().mockResolvedValue({ ids: ['mock-event-id'] }), + send: mockSend, }, }; }); diff --git a/services/emails/emails/ProposalSubmittedEmail.tsx b/services/emails/emails/ProposalSubmittedEmail.tsx new file mode 100644 index 000000000..5730f9bd6 --- /dev/null +++ b/services/emails/emails/ProposalSubmittedEmail.tsx @@ -0,0 +1,66 @@ +import { Button, Heading, Section, Text } from '@react-email/components'; + +import EmailTemplate from '../components/EmailTemplate'; + +export const ProposalSubmittedEmail = ({ + proposalName, + processTitle, + proposalUrl = 'https://common.oneproject.org/', +}: { + proposalName?: string | null; + processTitle?: string | null; + proposalUrl: string; +}) => { + const displayName = proposalName || 'Your proposal'; + + return ( + + + Proposal Submitted + + + Your proposal {displayName} has been submitted + {processTitle ? ( + <> + {' '} + to {processTitle} + + ) : null} + . + + +
+ +
+
+ ); +}; + +ProposalSubmittedEmail.subject = ( + proposalName?: string | null, + processTitle?: string | null, +) => { + const displayName = proposalName || 'Your proposal'; + if (processTitle) { + return `Your proposal "${displayName}" has been submitted to ${processTitle}`; + } + return `Your proposal "${displayName}" has been submitted`; +}; + +export default ProposalSubmittedEmail; diff --git a/services/emails/index.tsx b/services/emails/index.tsx index caaa38363..28e159df8 100644 --- a/services/emails/index.tsx +++ b/services/emails/index.tsx @@ -115,3 +115,4 @@ export * from './emails/OPInvitationEmail'; export * from './emails/OPRelationshipRequestEmail'; export * from './emails/CommentNotificationEmail'; export * from './emails/ReactionNotificationEmail'; +export * from './emails/ProposalSubmittedEmail'; diff --git a/services/events/src/types.ts b/services/events/src/types.ts index d69427dec..ef60fd608 100644 --- a/services/events/src/types.ts +++ b/services/events/src/types.ts @@ -42,4 +42,10 @@ export const Events = { ), }), }, + proposalSubmitted: { + name: 'proposal/submitted' as const, + schema: z.object({ + proposalId: z.string().uuid(), + }), + }, } as const; diff --git a/services/workflows/src/functions/notifications/index.ts b/services/workflows/src/functions/notifications/index.ts index c84004652..8d4d4f4f6 100644 --- a/services/workflows/src/functions/notifications/index.ts +++ b/services/workflows/src/functions/notifications/index.ts @@ -1,2 +1,3 @@ export * from './sendReactionNotification'; export * from './sendProfileInviteEmails'; +export * from './sendProposalSubmittedNotification'; diff --git a/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts b/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts new file mode 100644 index 000000000..b5d54ed66 --- /dev/null +++ b/services/workflows/src/functions/notifications/sendProposalSubmittedNotification.ts @@ -0,0 +1,118 @@ +import { OPURLConfig } from '@op/core'; +import { db } from '@op/db/client'; +import { + processInstances, + profileUsers, + profiles, + proposals, +} from '@op/db/schema'; +import { OPBatchSend, ProposalSubmittedEmail } from '@op/emails'; +import { Events, inngest } from '@op/events'; +import { eq } from 'drizzle-orm'; +import { alias } from 'drizzle-orm/pg-core'; + +const { proposalSubmitted } = Events; + +export const sendProposalSubmittedNotification = inngest.createFunction( + { + id: 'sendProposalSubmittedNotification', + debounce: { + key: 'event.data.proposalId', + period: '1m', + timeout: '3m', + }, + }, + { event: proposalSubmitted.name }, + async ({ event, step }) => { + const { proposalId } = proposalSubmitted.schema.parse(event.data); + + const proposalProfile = alias(profiles, 'proposal_profile'); + const processProfile = alias(profiles, 'process_profile'); + + // Step 1: Get proposal, its profile name, and the process instance profile (name + slug) + const proposalData = await step.run('get-proposal-data', async () => { + const result = await db + .select({ + proposalProfileId: proposals.profileId, + proposalProfileName: proposalProfile.name, + processProfileName: processProfile.name, + processProfileSlug: processProfile.slug, + }) + .from(proposals) + .innerJoin(proposalProfile, eq(proposals.profileId, proposalProfile.id)) + .innerJoin( + processInstances, + eq(proposals.processInstanceId, processInstances.id), + ) + .innerJoin( + processProfile, + eq(processInstances.profileId, processProfile.id), + ) + .where(eq(proposals.id, proposalId)) + .limit(1); + + return result[0]; + }); + + if (!proposalData) { + console.log('No proposal data found for proposal:', proposalId); + return; + } + + // Step 2: Get all collaborator emails + const collaborators = await step.run('get-collaborators', async () => { + return db + .select({ + email: profileUsers.email, + }) + .from(profileUsers) + .where(eq(profileUsers.profileId, proposalData.proposalProfileId)); + }); + + if (collaborators.length === 0) { + console.log('No collaborators found for proposal:', proposalId); + return; + } + + const proposalUrl = `${OPURLConfig('APP').ENV_URL}/decisions/${proposalData.processProfileSlug}/proposal/${proposalData.proposalProfileId}`; + + // Step 3: Send notification emails to all collaborators + const result = await step.run('send-emails', async () => { + try { + const emails = collaborators.map(({ email }) => ({ + to: email, + subject: ProposalSubmittedEmail.subject( + proposalData.proposalProfileName, + proposalData.processProfileName, + ), + component: () => + ProposalSubmittedEmail({ + proposalName: proposalData.proposalProfileName, + processTitle: proposalData.processProfileName, + proposalUrl, + }), + })); + + const { data, errors } = await OPBatchSend(emails); + + if (errors.length > 0) { + throw Error(`Email batch failed: ${JSON.stringify(errors)}`); + } + + return { + sent: data.length, + }; + } catch (error) { + console.error('Failed to send proposal submitted notifications:', { + error, + proposalId, + }); + throw error; + } + }); + + return { + message: `${result.sent} proposal submitted notification(s) sent`, + }; + }, +);