mirror of
https://github.com/documenso/documenso.git
synced 2025-11-10 04:22:32 +10:00
Compare commits
4 Commits
v1.6.0-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 Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
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 { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -101,6 +101,18 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
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 { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -104,6 +104,18 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</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>
|
</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 = {
|
export type UpdatePublicProfileOptions = {
|
||||||
userId: number;
|
userId: number;
|
||||||
url: string;
|
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({
|
const isUrlTaken = await prisma.user.findFirst({
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@ -37,10 +38,10 @@ export const updatePublicProfile = async ({ userId, url }: UpdatePublicProfileOp
|
|||||||
userProfile: {
|
userProfile: {
|
||||||
upsert: {
|
upsert: {
|
||||||
create: {
|
create: {
|
||||||
bio: '',
|
bio: bio,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
bio: '',
|
bio: bio,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -88,7 +88,7 @@ export const profileRouter = router({
|
|||||||
.input(ZUpdatePublicProfileMutationSchema)
|
.input(ZUpdatePublicProfileMutationSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
const { url } = input;
|
const { url, bio } = input;
|
||||||
|
|
||||||
if (IS_BILLING_ENABLED() && url.length < 6) {
|
if (IS_BILLING_ENABLED() && url.length < 6) {
|
||||||
const subscriptions = await getSubscriptionsByUserId({
|
const subscriptions = await getSubscriptionsByUserId({
|
||||||
@ -108,6 +108,7 @@ export const profileRouter = router({
|
|||||||
const user = await updatePublicProfile({
|
const user = await updatePublicProfile({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
url,
|
url,
|
||||||
|
bio,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, url: user.url };
|
return { success: true, url: user.url };
|
||||||
|
|||||||
@ -25,6 +25,13 @@ export const ZUpdatePublicProfileMutationSchema = z.object({
|
|||||||
.regex(/^[a-z0-9-]+$/, {
|
.regex(/^[a-z0-9-]+$/, {
|
||||||
message: 'Username can only container alphanumeric characters and dashes.',
|
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({
|
export const ZUpdatePasswordMutationSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user