mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
4 Commits
v1.5.6-rc.
...
feat/publi
| Author | SHA1 | Date | |
|---|---|---|---|
| 7746619dac | |||
| 23672d47f1 | |||
| c60bcc6309 | |||
| 69c175d38e |
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
export type PublicProfileSettingsLayout = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function PublicProfileSettingsLayout({ children }: PublicProfileSettingsLayout) {
|
||||
return <div className="col-span-12 md:col-span-9">{children}</div>;
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { Switch } from '@documenso/ui/primitives/switch';
|
||||
|
||||
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
|
||||
import { LinkTemplatesForm } from '~/components/forms/link-templates';
|
||||
import { PublicProfileForm } from '~/components/forms/public-profile';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Public profile',
|
||||
};
|
||||
|
||||
export default async function PublicProfileSettingsPage() {
|
||||
const { user } = await getRequiredServerComponentSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title="Public profile"
|
||||
subtitle="You can choose to enable/disable your profile for public view"
|
||||
className="justify mb-8"
|
||||
hideDivider
|
||||
>
|
||||
<div className="flex flex-row">
|
||||
<label className="mr-2 text-white" htmlFor="hide">
|
||||
Hide
|
||||
</label>
|
||||
<Switch />
|
||||
<label className="ml-2 text-white" htmlFor="show">
|
||||
Show
|
||||
</label>
|
||||
</div>
|
||||
</SettingsHeader>
|
||||
|
||||
<PublicProfileForm className="mb-8 max-w-xl" user={user} />
|
||||
|
||||
<LinkTemplatesForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -101,6 +101,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2 className="mr-2 h-5 w-5" />
|
||||
Public profile
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { Braces, CreditCard, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
import { Braces, CreditCard, Globe2, Lock, User, Users, Webhook } from 'lucide-react';
|
||||
|
||||
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@ -104,6 +104,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/settings/public-profile">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/public-profile') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2 className="mr-2 h-5 w-5" />
|
||||
Public profile
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
26
apps/web/src/components/forms/link-templates.tsx
Normal file
26
apps/web/src/components/forms/link-templates.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { File } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export const LinkTemplatesForm = () => {
|
||||
return (
|
||||
<div className={cn('flex max-w-xl flex-row items-center justify-between')}>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">My templates</h3>
|
||||
|
||||
<p className="text-muted-foreground text-sm md:mt-2">
|
||||
Create templates to display in your public profile
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button type="submit" variant="outline" className="self-end p-4">
|
||||
<File className="mr-2" /> Link template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
182
apps/web/src/components/forms/public-profile.tsx
Normal file
182
apps/web/src/components/forms/public-profile.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { TRPCClientError } from '@documenso/trpc/client';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
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 = z.object({
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.min(1, { message: 'Please enter a valid username.' })
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Username can only container alphanumeric characters and dashes.',
|
||||
}),
|
||||
bio: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(256, {
|
||||
message: 'Bio cannot be longer than 256 characters.',
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TPublicProfileFormSchema = z.infer<typeof ZPublicProfileFormSchema>;
|
||||
|
||||
export type PublicProfileFormProps = {
|
||||
className?: string;
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const PublicProfileForm = ({ className, user }: PublicProfileFormProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<TPublicProfileFormSchema>({
|
||||
values: {
|
||||
url: user.url || '',
|
||||
},
|
||||
resolver: zodResolver(ZPublicProfileFormSchema),
|
||||
});
|
||||
|
||||
const watchedBio = form.watch('bio');
|
||||
|
||||
const { mutateAsync: updatePublicProfile } = trpc.profile.updatePublicProfile.useMutation();
|
||||
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const onFormSubmit = async ({ url, bio }: TPublicProfileFormSchema) => {
|
||||
try {
|
||||
await updatePublicProfile({
|
||||
url,
|
||||
bio,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Public profile updated',
|
||||
description: 'Your public profile has been updated successfully.',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
|
||||
toast({
|
||||
title: 'An error occurred',
|
||||
description: err.message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: 'An unknown error occurred',
|
||||
variant: 'destructive',
|
||||
description:
|
||||
'We encountered an unknown error while attempting to sign you In. Please try again later.',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
const profileUrl = `documenso.com/u/${user.url}`;
|
||||
navigator.clipboard
|
||||
.writeText(profileUrl)
|
||||
.then(() => {
|
||||
toast({
|
||||
title: 'URL Copied',
|
||||
description: 'The profile URL has been copied to your clipboard.',
|
||||
duration: 3000,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to copy: ', err);
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to copy the URL to clipboard.',
|
||||
variant: 'destructive',
|
||||
duration: 3000,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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-8" disabled={isSubmitting}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Public profile URL</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="bg-muted flex w-full items-center rounded pl-2">
|
||||
<span className="text-xs">documenso.com/u/{user.url}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCopyUrl}
|
||||
className="flex items-center justify-center rounded p-2 hover:bg-gray-200"
|
||||
aria-label="Copy URL"
|
||||
>
|
||||
<Copy className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bio</FormLabel>
|
||||
<FormControl>
|
||||
<>
|
||||
<Textarea placeholder="Write about yourself..." {...field} />
|
||||
<div className="text-muted-foreground text-left text-sm">
|
||||
{256 - (watchedBio?.length || 0)} characters remaining
|
||||
</div>
|
||||
</>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<Button type="submit" loading={isSubmitting} className="self-end">
|
||||
{isSubmitting ? 'Saving changes...' : 'Save changes'}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@ -5,9 +5,10 @@ import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
export type UpdatePublicProfileOptions = {
|
||||
userId: number;
|
||||
url: string;
|
||||
bio?: string;
|
||||
};
|
||||
|
||||
export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOptions) => {
|
||||
export const updatePublicProfile = async ({ userId, url, bio }: UpdatePublicProfileOptions) => {
|
||||
const isUrlTaken = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
@ -37,10 +38,10 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
|
||||
userProfile: {
|
||||
upsert: {
|
||||
create: {
|
||||
bio: '',
|
||||
bio: bio,
|
||||
},
|
||||
update: {
|
||||
bio: '',
|
||||
bio: bio,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -88,7 +88,7 @@ export const profileRouter = router({
|
||||
.input(ZUpdatePublicProfileMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { url } = input;
|
||||
const { url, bio } = input;
|
||||
|
||||
if (IS_BILLING_ENABLED() && url.length < 6) {
|
||||
const subscriptions = await getSubscriptionsByUserId({
|
||||
@ -108,6 +108,7 @@ export const profileRouter = router({
|
||||
const user = await updatePublicProfile({
|
||||
userId: ctx.user.id,
|
||||
url,
|
||||
bio,
|
||||
});
|
||||
|
||||
return { success: true, url: user.url };
|
||||
|
||||
@ -25,6 +25,13 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Username can only container alphanumeric characters and dashes.',
|
||||
}),
|
||||
bio: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(256, {
|
||||
message: 'Bio cannot be longer than 256 characters.',
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const ZUpdatePasswordMutationSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user