mirror of
https://github.com/documenso/documenso.git
synced 2025-11-09 20:12:31 +10:00
286 lines
9.3 KiB
TypeScript
286 lines
9.3 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Plural, Trans } from '@lingui/react/macro';
|
|
import type { TeamProfile, UserProfile } from '@prisma/client';
|
|
import { motion } from 'framer-motion';
|
|
import { AnimatePresence } from 'framer-motion';
|
|
import { CheckSquareIcon, CopyIcon } from 'lucide-react';
|
|
import { useForm } from 'react-hook-form';
|
|
import type { z } from 'zod';
|
|
|
|
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
|
|
import { AppError } from '@documenso/lib/errors/app-error';
|
|
import { formatUserProfilePath } from '@documenso/lib/utils/public-profiles';
|
|
import {
|
|
MAX_PROFILE_BIO_LENGTH,
|
|
ZUpdatePublicProfileMutationSchema,
|
|
} from '@documenso/trpc/server/profile-router/schema';
|
|
import { cn } from '@documenso/ui/lib/utils';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@documenso/ui/primitives/form/form';
|
|
import { Input } from '@documenso/ui/primitives/input';
|
|
import { Textarea } from '@documenso/ui/primitives/textarea';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
|
|
export const ZPublicProfileFormSchema = ZUpdatePublicProfileMutationSchema.pick({
|
|
bio: true,
|
|
enabled: true,
|
|
url: true,
|
|
});
|
|
|
|
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
|
|
|
|
export type PublicProfileFormProps = {
|
|
className?: string;
|
|
profileUrl?: string | null;
|
|
teamUrl?: string;
|
|
onProfileUpdate: (data: TPublicProfileFormSchema) => Promise<unknown>;
|
|
profile: UserProfile | TeamProfile;
|
|
};
|
|
export const PublicProfileForm = ({
|
|
className,
|
|
profileUrl,
|
|
profile,
|
|
teamUrl,
|
|
onProfileUpdate,
|
|
}: PublicProfileFormProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const [, copy] = useCopyToClipboard();
|
|
|
|
const [copiedTimeout, setCopiedTimeout] = useState<NodeJS.Timeout | null>(null);
|
|
|
|
const form = useForm<TPublicProfileFormSchema>({
|
|
values: {
|
|
url: profileUrl ?? '',
|
|
bio: profile?.bio ?? '',
|
|
},
|
|
resolver: zodResolver(ZPublicProfileFormSchema),
|
|
});
|
|
|
|
const isSubmitting = form.formState.isSubmitting;
|
|
|
|
const onFormSubmit = async (data: TPublicProfileFormSchema) => {
|
|
try {
|
|
await onProfileUpdate(data);
|
|
|
|
toast({
|
|
title: _(msg`Success`),
|
|
description: _(msg`Your public profile has been updated.`),
|
|
duration: 5000,
|
|
});
|
|
|
|
form.reset({
|
|
url: data.url,
|
|
bio: data.bio,
|
|
});
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
switch (error.code) {
|
|
case 'PREMIUM_PROFILE_URL':
|
|
case 'PROFILE_URL_TAKEN':
|
|
form.setError('url', {
|
|
type: 'manual',
|
|
message: error.message,
|
|
});
|
|
|
|
break;
|
|
|
|
default:
|
|
toast({
|
|
title: _(msg`An unknown error occurred`),
|
|
description: _(
|
|
msg`We encountered an unknown error while attempting to update your public profile. Please try again later.`,
|
|
),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const onCopy = async () => {
|
|
await copy(formatUserProfilePath(form.getValues('url') ?? '')).then(() => {
|
|
toast({
|
|
title: _(msg`Copied to clipboard`),
|
|
description: _(msg`The profile link has been copied to your clipboard`),
|
|
});
|
|
});
|
|
|
|
if (copiedTimeout) {
|
|
clearTimeout(copiedTimeout);
|
|
}
|
|
|
|
setCopiedTimeout(
|
|
setTimeout(() => {
|
|
setCopiedTimeout(null);
|
|
}, 2000),
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Form {...form}>
|
|
<form
|
|
className={cn('flex w-full flex-col gap-y-4', className)}
|
|
onSubmit={form.handleSubmit(onFormSubmit)}
|
|
>
|
|
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
|
|
<FormField
|
|
control={form.control}
|
|
name="url"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
<Trans>Public profile URL</Trans>
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} disabled={field.disabled || teamUrl !== undefined} />
|
|
</FormControl>
|
|
|
|
{teamUrl && (
|
|
<p className="text-muted-foreground text-xs">
|
|
<Trans>
|
|
You can update the profile URL by updating the team URL in the general
|
|
settings page.
|
|
</Trans>
|
|
</p>
|
|
)}
|
|
|
|
<div className="h-8">
|
|
{!form.formState.errors.url && (
|
|
<div className="text-muted-foreground h-8 text-sm">
|
|
{field.value ? (
|
|
<div>
|
|
<Button
|
|
type="button"
|
|
variant="none"
|
|
className="h-7 rounded bg-neutral-50 pl-2 pr-0.5 font-normal dark:border dark:border-neutral-500 dark:bg-neutral-600"
|
|
onClick={async () => onCopy()}
|
|
>
|
|
<p>
|
|
{formatUserProfilePath('').replace(/https?:\/\//, '')}
|
|
<span className="font-semibold">{field.value}</span>
|
|
</p>
|
|
|
|
<div className="ml-1 flex h-6 w-6 items-center justify-center rounded transition-all hover:bg-neutral-200 hover:active:bg-neutral-300 dark:hover:bg-neutral-500 dark:hover:active:bg-neutral-400">
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
<motion.div
|
|
key={copiedTimeout ? 'copied' : 'copy'}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0, transition: { duration: 0.1 } }}
|
|
className="absolute"
|
|
>
|
|
{copiedTimeout ? (
|
|
<CheckSquareIcon className="h-3.5 w-3.5" />
|
|
) : (
|
|
<CopyIcon className="h-3.5 w-3.5" />
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<p>
|
|
<Trans>A unique URL to access your profile</Trans>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<FormMessage />
|
|
</div>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="bio"
|
|
render={({ field }) => {
|
|
const remaningLength = MAX_PROFILE_BIO_LENGTH - (field.value || '').length;
|
|
|
|
return (
|
|
<FormItem>
|
|
<FormLabel>Bio</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
{...field}
|
|
placeholder={
|
|
teamUrl ? _(msg`Write about the team`) : _(msg`Write about yourself`)
|
|
}
|
|
/>
|
|
</FormControl>
|
|
|
|
{!form.formState.errors.bio && (
|
|
<p className="text-muted-foreground text-sm">
|
|
{remaningLength >= 0 ? (
|
|
<Plural
|
|
value={remaningLength}
|
|
one={<Trans># character remaining</Trans>}
|
|
other={<Trans># characters remaining</Trans>}
|
|
/>
|
|
) : (
|
|
<Plural
|
|
value={Math.abs(remaningLength)}
|
|
one={<Trans># character over the limit</Trans>}
|
|
other={<Trans># characters over the limit</Trans>}
|
|
/>
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
<FormMessage />
|
|
</FormItem>
|
|
);
|
|
}}
|
|
/>
|
|
|
|
<div className="flex flex-row justify-end space-x-4">
|
|
<AnimatePresence>
|
|
{form.formState.isDirty && (
|
|
<motion.div
|
|
initial={{
|
|
opacity: 0,
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
}}
|
|
exit={{
|
|
opacity: 0,
|
|
}}
|
|
>
|
|
<Button type="button" variant="secondary" onClick={() => form.reset()}>
|
|
<Trans>Reset</Trans>
|
|
</Button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<Button
|
|
type="submit"
|
|
className="transition-opacity"
|
|
disabled={!form.formState.isDirty}
|
|
loading={form.formState.isSubmitting}
|
|
>
|
|
<Trans>Update</Trans>
|
|
</Button>
|
|
</div>
|
|
</fieldset>
|
|
</form>
|
|
</Form>
|
|
);
|
|
};
|