diff --git a/apps/remix/app/components/(teams)/forms/update-team-form.tsx b/apps/remix/app/components/(teams)/forms/update-team-form.tsx index 839b52f67..c11b25bc2 100644 --- a/apps/remix/app/components/(teams)/forms/update-team-form.tsx +++ b/apps/remix/app/components/(teams)/forms/update-team-form.tsx @@ -3,6 +3,7 @@ import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { AnimatePresence, motion } from 'framer-motion'; import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import type { z } from 'zod'; import { WEBAPP_BASE_URL } from '@documenso/lib/constants/app'; diff --git a/apps/remix/app/components/(teams)/tables/teams-member-page-data-table.tsx b/apps/remix/app/components/(teams)/tables/teams-member-page-data-table.tsx deleted file mode 100644 index 2aebb4579..000000000 --- a/apps/remix/app/components/(teams)/tables/teams-member-page-data-table.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { Trans, msg } from '@lingui/macro'; -import { useLingui } from '@lingui/react'; -import type { TeamMemberRole } from '@prisma/client'; -import { Link, useLocation, useNavigate, useSearchParams } from 'react-router'; - -import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value'; -import { Input } from '@documenso/ui/primitives/input'; -import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs'; - -import { TeamMemberInvitesDataTable } from '~/components/(teams)/tables/team-member-invites-data-table'; -import { TeamMembersDataTable } from '~/components/(teams)/tables/team-members-data-table'; - -export type TeamsMemberPageDataTableProps = { - currentUserTeamRole: TeamMemberRole; - teamId: number; - teamName: string; - teamOwnerUserId: number; -}; - -export const TeamsMemberPageDataTable = ({ - currentUserTeamRole, - teamId, - teamName, - teamOwnerUserId, -}: TeamsMemberPageDataTableProps) => { - const { _ } = useLingui(); - - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const { pathname } = useLocation(); - - const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? ''); - - const debouncedSearchQuery = useDebouncedValue(searchQuery, 500); - - const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members'; - - /** - * Handle debouncing the search query. - */ - useEffect(() => { - if (!pathname) { - return; - } - - const params = new URLSearchParams(searchParams?.toString()); - - params.set('query', debouncedSearchQuery); - - if (debouncedSearchQuery === '') { - params.delete('query'); - } - - void navigate(`${pathname}?${params.toString()}`); - }, [debouncedSearchQuery, pathname, navigate, searchParams]); - - return ( -
-
- setSearchQuery(e.target.value)} - placeholder={_(msg`Search`)} - /> - - - - - - Active - - - - - - Pending - - - - -
- - {currentTab === 'invites' ? ( - - ) : ( - - )} -
- ); -}; diff --git a/apps/remix/app/components/dialogs/team-delete-dialog.tsx b/apps/remix/app/components/dialogs/team-delete-dialog.tsx index c0c665823..a91e32302 100644 --- a/apps/remix/app/components/dialogs/team-delete-dialog.tsx +++ b/apps/remix/app/components/dialogs/team-delete-dialog.tsx @@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import { z } from 'zod'; import { AppError } from '@documenso/lib/errors/app-error'; diff --git a/apps/remix/app/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx similarity index 96% rename from apps/remix/app/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx rename to apps/remix/app/components/dialogs/webhook-create-dialog.tsx index 724b6adbb..c2c95bca0 100644 --- a/apps/remix/app/components/(dashboard)/settings/webhooks/create-webhook-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-create-dialog.tsx @@ -35,17 +35,17 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useOptionalCurrentTeam } from '~/providers/team'; -import { TriggerMultiSelectCombobox } from './trigger-multiselect-combobox'; +import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox'; const ZCreateWebhookFormSchema = ZCreateWebhookMutationSchema.omit({ teamId: true }); type TCreateWebhookFormSchema = z.infer; -export type CreateWebhookDialogProps = { +export type WebhookCreateDialogProps = { trigger?: React.ReactNode; } & Omit; -export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogProps) => { +export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -185,7 +185,7 @@ export const CreateWebhookDialog = ({ trigger, ...props }: CreateWebhookDialogPr Triggers - { onChange(values); diff --git a/apps/remix/app/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx similarity index 97% rename from apps/remix/app/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx rename to apps/remix/app/components/dialogs/webhook-delete-dialog.tsx index 82cc61c22..d38b0a2b6 100644 --- a/apps/remix/app/components/(dashboard)/settings/webhooks/delete-webhook-dialog.tsx +++ b/apps/remix/app/components/dialogs/webhook-delete-dialog.tsx @@ -33,13 +33,13 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { useOptionalCurrentTeam } from '~/providers/team'; -export type DeleteWebhookDialogProps = { +export type WebhookDeleteDialogProps = { webhook: Pick; onDelete?: () => void; children: React.ReactNode; }; -export const DeleteWebhookDialog = ({ webhook, children }: DeleteWebhookDialogProps) => { +export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogProps) => { const { _ } = useLingui(); const { toast } = useToast(); diff --git a/apps/remix/app/components/forms/team-branding-preferences-form.tsx b/apps/remix/app/components/forms/team-branding-preferences-form.tsx new file mode 100644 index 000000000..3f937a0b8 --- /dev/null +++ b/apps/remix/app/components/forms/team-branding-preferences-form.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, msg } from '@lingui/macro'; +import { useLingui } from '@lingui/react'; +import { Loader } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { getFile } from '@documenso/lib/universal/upload/get-file'; +import { putFile } from '@documenso/lib/universal/upload/put-file'; +import type { Team, TeamGlobalSettings } from '@documenso/prisma/client'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from '@documenso/ui/primitives/form/form'; +import { Input } from '@documenso/ui/primitives/input'; +import { Switch } from '@documenso/ui/primitives/switch'; +import { Textarea } from '@documenso/ui/primitives/textarea'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB +const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +const ZTeamBrandingPreferencesFormSchema = z.object({ + brandingEnabled: z.boolean(), + brandingLogo: z + .instanceof(File) + .refine((file) => file.size <= MAX_FILE_SIZE, 'File size must be less than 5MB') + .refine( + (file) => ACCEPTED_FILE_TYPES.includes(file.type), + 'Only .jpg, .png, and .webp files are accepted', + ) + .nullish(), + brandingUrl: z.string().url().optional().or(z.literal('')), + brandingCompanyDetails: z.string().max(500).optional(), +}); + +type TTeamBrandingPreferencesFormSchema = z.infer; + +export type TeamBrandingPreferencesFormProps = { + team: Team; + settings?: TeamGlobalSettings | null; +}; + +export function TeamBrandingPreferencesForm({ team, settings }: TeamBrandingPreferencesFormProps) { + const { _ } = useLingui(); + const { toast } = useToast(); + + const [previewUrl, setPreviewUrl] = useState(''); + const [hasLoadedPreview, setHasLoadedPreview] = useState(false); + + const { mutateAsync: updateTeamBrandingSettings } = + trpc.team.updateTeamBrandingSettings.useMutation(); + + const form = useForm({ + defaultValues: { + brandingEnabled: settings?.brandingEnabled ?? false, + brandingUrl: settings?.brandingUrl ?? '', + brandingLogo: undefined, + brandingCompanyDetails: settings?.brandingCompanyDetails ?? '', + }, + resolver: zodResolver(ZTeamBrandingPreferencesFormSchema), + }); + + const isBrandingEnabled = form.watch('brandingEnabled'); + + const onSubmit = async (data: TTeamBrandingPreferencesFormSchema) => { + try { + const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data; + + let uploadedBrandingLogo = settings?.brandingLogo; + + if (brandingLogo) { + uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo)); + } + + if (brandingLogo === null) { + uploadedBrandingLogo = ''; + } + + await updateTeamBrandingSettings({ + teamId: team.id, + settings: { + brandingEnabled, + brandingLogo: uploadedBrandingLogo, + brandingUrl, + brandingCompanyDetails, + }, + }); + + toast({ + title: _(msg`Branding preferences updated`), + description: _(msg`Your branding preferences have been updated`), + }); + } catch (err) { + toast({ + title: _(msg`Something went wrong`), + description: _( + msg`We were unable to update your branding preferences at this time, please try again later`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (settings?.brandingLogo) { + const file = JSON.parse(settings.brandingLogo); + + if ('type' in file && 'data' in file) { + void getFile(file).then((binaryData) => { + const objectUrl = URL.createObjectURL(new Blob([binaryData])); + + setPreviewUrl(objectUrl); + setHasLoadedPreview(true); + }); + + return; + } + } + + setHasLoadedPreview(true); + }, [settings?.brandingLogo]); + + // Cleanup ObjectURL on unmount or when previewUrl changes + useEffect(() => { + return () => { + if (previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + }; + }, [previewUrl]); + + return ( +
+ +
+ ( + + Enable Custom Branding + +
+ + + +
+ + + Enable custom branding for all documents in this team. + +
+ )} + /> + +
+ {!isBrandingEnabled &&
} + + ( + + Branding Logo + +
+
+ {previewUrl ? ( + Logo preview + ) : ( +
+ Please upload a logo + {!hasLoadedPreview && ( +
+ +
+ )} +
+ )} +
+ +
+ + { + const file = e.target.files?.[0]; + + if (file) { + if (previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(previewUrl); + } + + const objectUrl = URL.createObjectURL(file); + + setPreviewUrl(objectUrl); + + onChange(file); + } + }} + className={cn( + 'h-auto p-2', + 'file:text-primary hover:file:bg-primary/90', + 'file:mr-4 file:cursor-pointer file:rounded-md file:border-0', + 'file:p-2 file:py-2 file:font-medium', + 'file:bg-primary file:text-primary-foreground', + !isBrandingEnabled && 'cursor-not-allowed', + )} + {...field} + /> + + +
+ +
+
+ + + Upload your brand logo (max 5MB, JPG, PNG, or WebP) + +
+
+ )} + /> + + ( + + Brand Website + + + + + + + Your brand website URL + + + )} + /> + + ( + + Brand Details + + +