This commit is contained in:
David Nguyen
2025-02-03 14:10:28 +11:00
parent 28fb35327d
commit b2af10173a
141 changed files with 7340 additions and 394 deletions

View File

@ -0,0 +1,47 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { Link2Icon } from 'lucide-react';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDirectLinkBadgeProps = {
token: string;
enabled: boolean;
className?: string;
};
export const TemplateDirectLinkBadge = ({
token,
enabled,
className,
}: TemplateDirectLinkBadgeProps) => {
const [, copy] = useCopyToClipboard();
const { _ } = useLingui();
const { toast } = useToast();
const onCopyClick = async (token: string) =>
copy(formatDirectTemplatePath(token)).then(() => {
toast({
title: _(msg`Copied to clipboard`),
description: _(msg`The direct link has been copied to your clipboard`),
});
});
return (
<button
title="Copy direct link"
className={cn(
'flex flex-row items-center rounded border border-neutral-300 bg-neutral-200 px-1.5 py-0.5 text-xs dark:border-neutral-500 dark:bg-neutral-600',
className,
)}
onClick={async () => onCopyClick(token)}
>
<Link2Icon className="mr-1 h-3 w-3" />
{enabled ? <Trans>direct link</Trans> : <Trans>direct link disabled</Trans>}
</button>
);
};

View File

@ -0,0 +1,294 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import type { TTemplate } from '@documenso/lib/types/template';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root';
import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types';
import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer';
import { Stepper } from '@documenso/ui/primitives/stepper';
import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields';
import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types';
import { AddTemplatePlaceholderRecipientsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients';
import type { TAddTemplatePlacholderRecipientsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-placeholder-recipients.types';
import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-settings';
import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useOptionalCurrentTeam } from '~/providers/team';
export type TemplateEditFormProps = {
className?: string;
initialTemplate: TTemplate;
isEnterprise: boolean;
templateRootPath: string;
};
type EditTemplateStep = 'settings' | 'signers' | 'fields';
const EditTemplateSteps: EditTemplateStep[] = ['settings', 'signers', 'fields'];
export const TemplateEditForm = ({
initialTemplate,
className,
isEnterprise,
templateRootPath,
}: TemplateEditFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const team = useOptionalCurrentTeam();
const [step, setStep] = useState<EditTemplateStep>('settings');
const [isDocumentPdfLoaded, setIsDocumentPdfLoaded] = useState(false);
const utils = trpc.useUtils();
const { data: template, refetch: refetchTemplate } = trpc.template.getTemplateById.useQuery(
{
templateId: initialTemplate.id,
},
{
initialData: initialTemplate,
...SKIP_QUERY_BATCH_META,
},
);
const { recipients, fields, templateDocumentData } = template;
const documentFlow: Record<EditTemplateStep, DocumentFlowStep> = {
settings: {
title: msg`General`,
description: msg`Configure general settings for the template.`,
stepIndex: 1,
},
signers: {
title: msg`Add Placeholders`,
description: msg`Add all relevant placeholders for each recipient.`,
stepIndex: 2,
},
fields: {
title: msg`Add Fields`,
description: msg`Add all relevant fields for each recipient.`,
stepIndex: 3,
},
};
const currentDocumentFlow = documentFlow[step];
const { mutateAsync: updateTemplateSettings } = trpc.template.updateTemplate.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: addTemplateFields } = trpc.field.addTemplateFields.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const { mutateAsync: setRecipients } = trpc.recipient.setTemplateRecipients.useMutation({
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
onSuccess: (newData) => {
utils.template.getTemplateById.setData(
{
templateId: initialTemplate.id,
},
(oldData) => ({ ...(oldData || initialTemplate), ...newData }),
);
},
});
const onAddSettingsFormSubmit = async (data: TAddTemplateSettingsFormSchema) => {
try {
await updateTemplateSettings({
templateId: template.id,
data: {
title: data.title,
externalId: data.externalId || null,
visibility: data.visibility,
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: {
...data.meta,
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
setStep('signers');
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while updating the document settings.`),
variant: 'destructive',
});
}
};
const onAddTemplatePlaceholderFormSubmit = async (
data: TAddTemplatePlacholderRecipientsFormSchema,
) => {
try {
await Promise.all([
updateTemplateSettings({
templateId: template.id,
meta: {
signingOrder: data.signingOrder,
},
}),
setRecipients({
templateId: template.id,
recipients: data.signers,
}),
]);
setStep('fields');
} catch (err) {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding signers.`),
variant: 'destructive',
});
}
};
const onAddFieldsFormSubmit = async (data: TAddTemplateFieldsFormSchema) => {
try {
await addTemplateFields({
templateId: template.id,
fields: data.fields,
});
await updateTemplateSettings({
templateId: template.id,
meta: {
typedSignatureEnabled: data.typedSignatureEnabled,
},
});
// Clear all field data from localStorage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith('field_')) {
localStorage.removeItem(key);
}
}
toast({
title: _(msg`Template saved`),
description: _(msg`Your templates has been saved successfully.`),
duration: 5000,
});
await navigate(templateRootPath);
} catch (err) {
console.error(err);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while adding fields.`),
variant: 'destructive',
});
}
};
/**
* Refresh the data in the background when steps change.
*/
useEffect(() => {
void refetchTemplate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
return (
<div className={cn('grid w-full grid-cols-12 gap-8', className)}>
<Card
className="relative col-span-12 rounded-xl before:rounded-xl lg:col-span-6 xl:col-span-7"
gradient
>
<CardContent className="p-2">
<LazyPDFViewer
key={templateDocumentData.id}
documentData={templateDocumentData}
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>
</Card>
<div className="col-span-12 lg:col-span-6 xl:col-span-5">
<DocumentFlowFormContainer
className="lg:h-[calc(100vh-6rem)]"
onSubmit={(e) => e.preventDefault()}
>
<Stepper
currentStep={currentDocumentFlow.stepIndex}
setCurrentStep={(step) => setStep(EditTemplateSteps[step - 1])}
>
<AddTemplateSettingsFormPartial
key={recipients.length}
template={template}
currentTeamMemberRole={team?.currentTeamMember?.role}
documentFlow={documentFlow.settings}
recipients={recipients}
fields={fields}
onSubmit={onAddSettingsFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplatePlaceholderRecipientsFormPartial
key={recipients.length}
documentFlow={documentFlow.signers}
recipients={recipients}
fields={fields}
signingOrder={template.templateMeta?.signingOrder}
templateDirectLink={template.directLink}
onSubmit={onAddTemplatePlaceholderFormSubmit}
isEnterprise={isEnterprise}
isDocumentPdfLoaded={isDocumentPdfLoaded}
/>
<AddTemplateFieldsFormPartial
key={fields.length}
documentFlow={documentFlow.fields}
recipients={recipients}
fields={fields}
onSubmit={onAddFieldsFormSubmit}
teamId={team?.id}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
/>
</Stepper>
</DocumentFlowFormContainer>
</div>
</div>
);
};

View File

@ -0,0 +1,271 @@
import { useMemo } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { trpc } from '@documenso/trpc/react';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { SelectItem } from '@documenso/ui/primitives/select';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { StackAvatarsWithTooltip } from '~/components/(dashboard)/avatar/stack-avatars-with-tooltip';
import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { DocumentStatus } from '~/components/formatter/document-status';
import { SearchParamSelector } from '~/components/forms/search-param-selector';
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
import { DataTableTitle } from '~/components/tables/documents-table-title';
import { useOptionalCurrentTeam } from '~/providers/team';
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
DOCUMENT: msg`Document`,
TEMPLATE: msg`Template`,
TEMPLATE_DIRECT_LINK: msg`Direct link`,
};
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
source: z
.nativeEnum(DocumentSource)
.optional()
.catch(() => undefined),
status: z
.nativeEnum(DocumentStatusEnum)
.optional()
.catch(() => undefined),
});
type TemplatePageViewDocumentsTableProps = {
templateId: number;
};
export const TemplatePageViewDocumentsTable = ({
templateId,
}: TemplatePageViewDocumentsTableProps) => {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useOptionalCurrentTeam();
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
{
templateId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
query: parsedSearchParams.query,
source: parsedSearchParams.source,
status: parsedSearchParams.status,
},
{
placeholderData: (previousData) => previousData,
},
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
});
};
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
},
{
header: _(msg`Title`),
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
cell: ({ row }) => (
<StackAvatarsWithTooltip
recipients={row.original.recipients}
documentStatus={row.original.status}
/>
),
},
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
size: 140,
},
{
header: () => (
<div className="flex flex-row items-center">
<Trans>Source</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-foreground max-w-md space-y-2 !p-0">
<ul className="text-muted-foreground space-y-0.5 divide-y [&>li]:p-4">
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Trans>Template</Trans>
</h2>
<p>
<Trans>
This document was created by you or a team member using the template above.
</Trans>
</p>
</li>
<li>
<h2 className="mb-2 flex flex-row items-center font-semibold">
<Trans>Direct Link</Trans>
</h2>
<p>
<Trans>This document was created using a direct link.</Trans>
</p>
</li>
</ul>
</TooltipContent>
</Tooltip>
</div>
),
accessorKey: 'type',
cell: ({ row }) => (
<div className="flex flex-row items-center">
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
</div>
),
},
{
id: 'actions',
header: _(msg`Actions`),
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
return (
<div>
<div className="mb-4 flex flex-row space-x-4">
<DocumentSearch />
<SearchParamSelector
paramKey="status"
isValueValid={(value) =>
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Status</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.COMPLETED}>
<Trans>Completed</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.PENDING}>
<Trans>Pending</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.DRAFT}>
<Trans>Draft</Trans>
</SelectItem>
</SearchParamSelector>
<SearchParamSelector
paramKey="source"
isValueValid={(value) =>
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Source</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE}>
<Trans>Template</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
<Trans>Direct Link</Trans>
</SelectItem>
</SearchParamSelector>
<PeriodSelector />
</div>
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
error={{
enable: isLoadingError,
}}
skeleton={{
enable: isLoading,
rows: 3,
component: (
<>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24 rounded-full" />
</TableCell>
<TableCell className="py-4 pr-4">
<Skeleton className="h-12 w-12 flex-shrink-0 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-12 rounded-full" />
</TableCell>
<TableCell>
<div className="flex flex-row justify-end space-x-2">
<Skeleton className="h-10 w-20 rounded" />
</div>
</TableCell>
</>
),
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
</div>
);
};

View File

@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Template, User } from '@prisma/client';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
export type TemplatePageViewInformationProps = {
userId: number;
template: Template & {
user: Pick<User, 'id' | 'name' | 'email'>;
};
};
export const TemplatePageViewInformation = ({
template,
userId,
}: TemplatePageViewInformationProps) => {
const isMounted = useIsMounted();
const { _, i18n } = useLingui();
const templateInformation = useMemo(() => {
return [
{
description: msg`Uploaded by`,
value:
userId === template.userId ? _(msg`You`) : (template.user.name ?? template.user.email),
},
{
description: msg`Created`,
value: i18n.date(template.createdAt, { dateStyle: 'medium' }),
},
{
description: msg`Last modified`,
value: DateTime.fromJSDate(template.updatedAt)
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, template, userId]);
return (
<section className="dark:bg-background text-foreground border-border bg-widget flex flex-col rounded-xl border">
<h1 className="px-4 py-3 font-medium">
<Trans>Information</Trans>
</h1>
<ul className="divide-y border-t">
{templateInformation.map((item, i) => (
<li
key={i}
className="flex items-center justify-between px-4 py-2.5 text-sm last:border-b"
>
<span className="text-muted-foreground">{_(item.description)}</span>
<span>{item.value}</span>
</li>
))}
</ul>
</section>
);
};

View File

@ -0,0 +1,155 @@
import { Trans } from '@lingui/macro';
import { DocumentSource } from '@prisma/client';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
export type TemplatePageViewRecentActivityProps = {
templateId: number;
documentRootPath: string;
};
export const TemplatePageViewRecentActivity = ({
templateId,
documentRootPath,
}: TemplatePageViewRecentActivityProps) => {
const { data, isLoading, isLoadingError, refetch } = trpc.document.findDocuments.useQuery({
templateId,
orderByColumn: 'createdAt',
orderByDirection: 'asc',
perPage: 5,
});
const results = data ?? {
data: [],
perPage: 10,
currentPage: 1,
totalPages: 1,
};
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between border-b px-4 py-3">
<h1 className="text-foreground font-medium">
<Trans>Recent documents</Trans>
</h1>
{/* Can add dropdown menu here for additional options. */}
</div>
{isLoading && (
<div className="flex h-full items-center justify-center py-16">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
</div>
)}
{isLoadingError && (
<div className="flex h-full flex-col items-center justify-center py-16">
<p className="text-foreground/80 text-sm">
<Trans>Unable to load documents</Trans>
</p>
<button
onClick={async () => refetch()}
className="text-foreground/70 hover:text-muted-foreground mt-2 text-sm"
>
<Trans>Click here to retry</Trans>
</button>
</div>
)}
{data && (
<>
<ul role="list" className="space-y-6 p-4">
{data.data.length > 0 && results.totalPages > 1 && (
<li className="relative flex gap-x-4">
<div className="absolute -bottom-6 left-0 top-0 flex w-6 justify-center">
<div className="bg-border w-px" />
</div>
<div className="bg-widget relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<button
onClick={() => {
window.scrollTo({
top: document.getElementById('documents')?.offsetTop,
behavior: 'smooth',
});
}}
className="text-foreground/70 hover:text-muted-foreground flex items-center text-xs"
>
<Trans>View more</Trans>
</button>
</li>
)}
{results.data.length === 0 && (
<div className="flex items-center justify-center py-4">
<p className="text-muted-foreground/70 text-sm">
<Trans>No recent documents</Trans>
</p>
</div>
)}
{results.data.map((document, documentIndex) => (
<li key={document.id} className="relative flex gap-x-4">
<div
className={cn(
documentIndex === results.data.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center',
)}
>
<div className="bg-border w-px" />
</div>
<div className="bg-widget text-foreground/40 relative flex h-6 w-6 flex-none items-center justify-center">
<div className="bg-widget h-1.5 w-1.5 rounded-full ring-1 ring-gray-300 dark:ring-neutral-600" />
</div>
<Link
to={`${documentRootPath}/${document.id}`}
className="text-muted-foreground dark:text-muted-foreground/70 flex-auto truncate py-0.5 text-xs leading-5"
>
{match(document.source)
.with(DocumentSource.DOCUMENT, DocumentSource.TEMPLATE, () => (
<Trans>
Document created by <span className="font-bold">{document.user.name}</span>
</Trans>
))
.with(DocumentSource.TEMPLATE_DIRECT_LINK, () => (
<Trans>
Document created using a <span className="font-bold">direct link</span>
</Trans>
))
.exhaustive()}
</Link>
<time className="text-muted-foreground dark:text-muted-foreground/70 flex-none py-0.5 text-xs leading-5">
{DateTime.fromJSDate(document.createdAt).toRelative({ style: 'short' })}
</time>
</li>
))}
</ul>
<Button
className="mx-4 mb-4"
onClick={() => {
window.scrollTo({
top: document.getElementById('documents')?.offsetTop,
behavior: 'smooth',
});
}}
>
<Trans>View all related documents</Trans>
</Button>
</>
)}
</section>
);
};

View File

@ -0,0 +1,68 @@
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import type { Recipient, Template } from '@prisma/client';
import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
template: Template & {
recipients: Recipient[];
};
templateRootPath: string;
};
export const TemplatePageViewRecipients = ({
template,
templateRootPath,
}: TemplatePageViewRecipientsProps) => {
const { _ } = useLingui();
const recipients = template.recipients;
return (
<section className="dark:bg-background border-border bg-widget flex flex-col rounded-xl border">
<div className="flex flex-row items-center justify-between px-4 py-3">
<h1 className="text-foreground font-medium">
<Trans>Recipients</Trans>
</h1>
<Link
to={`${templateRootPath}/${template.id}/edit?step=signers`}
title={_(msg`Modify recipients`)}
className="flex flex-row items-center justify-between"
>
{recipients.length === 0 ? (
<PlusIcon className="ml-2 h-4 w-4" />
) : (
<PenIcon className="ml-2 h-3 w-3" />
)}
</Link>
</div>
<ul className="text-muted-foreground divide-y border-t">
{recipients.length === 0 && (
<li className="flex flex-col items-center justify-center py-6 text-sm">
<Trans>No recipients</Trans>
</li>
)}
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
}
/>
</li>
))}
</ul>
</section>
);
};