diff --git a/apps/platform/trpc/routers/authRouter/passkeyRouter.ts b/apps/platform/trpc/routers/authRouter/passkeyRouter.ts index 82e5edae..669f5fd8 100644 --- a/apps/platform/trpc/routers/authRouter/passkeyRouter.ts +++ b/apps/platform/trpc/routers/authRouter/passkeyRouter.ts @@ -37,7 +37,7 @@ export const passkeyRouter = router({ username: zodSchemas.username() }) ) - .query(async ({ input, ctx }) => { + .mutation(async ({ input, ctx }) => { const { db } = ctx; const { username } = input; const { available, error } = await validateUsername(db, input.username); diff --git a/apps/platform/trpc/routers/authRouter/passwordRouter.ts b/apps/platform/trpc/routers/authRouter/passwordRouter.ts index 5bb3c953..425ef5e8 100644 --- a/apps/platform/trpc/routers/authRouter/passwordRouter.ts +++ b/apps/platform/trpc/routers/authRouter/passwordRouter.ts @@ -24,6 +24,50 @@ import { ms } from '@u22n/utils/ms'; import { ratelimiter } from '~platform/trpc/ratelimit'; export const passwordRouter = router({ + signUpWithPassword: publicProcedure + .unstable_concat(turnstileProcedure) + .use(ratelimiter({ limit: 10, namespace: 'signUp.password' })) + .input( + z.object({ + username: zodSchemas.username(), + password: strongPasswordSchema + }) + ) + .mutation(async ({ ctx, input }) => { + const { password, username } = input; + const { db, event } = ctx; + + const { accountId, publicId } = await db.transaction(async (tx) => { + const { available, error } = await validateUsername(tx, username); + if (!available) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Username Error : ${error}` + }); + } + const passwordHash = await new Argon2id().hash(password); + const publicId = typeIdGenerator('account'); + + const newUser = await tx.insert(accounts).values({ + username, + publicId, + passwordHash + }); + + return { + accountId: Number(newUser.insertId), + publicId + }; + }); + + await createLuciaSessionCookie(event, { + accountId, + username, + publicId + }); + + return { success: true }; + }), signUpWithPassword2FA: publicProcedure .unstable_concat(turnstileProcedure) .use(ratelimiter({ limit: 10, namespace: 'signUp.password' })) diff --git a/apps/platform/trpc/routers/authRouter/signupRouter.ts b/apps/platform/trpc/routers/authRouter/signupRouter.ts index e330494f..9d1c3886 100644 --- a/apps/platform/trpc/routers/authRouter/signupRouter.ts +++ b/apps/platform/trpc/routers/authRouter/signupRouter.ts @@ -52,9 +52,7 @@ export const signupRouter = router({ username: zodSchemas.username() }) ) - .query(async ({ ctx, input }) => { - return await validateUsername(ctx.db, input.username); - }), + .query(({ ctx, input }) => validateUsername(ctx.db, input.username)), checkPasswordStrength: publicProcedure .use(ratelimiter({ limit: 50, namespace: 'check.password' })) .input( diff --git a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts index 84168e07..135ad67e 100644 --- a/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts +++ b/apps/platform/trpc/routers/orgRouter/orgCrudRouter.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { router, accountProcedure } from '~platform/trpc/trpc'; import type { DBType } from '@u22n/database'; -import { eq, and } from '@u22n/database/orm'; +import { eq, and, like } from '@u22n/database/orm'; import { orgs, orgMembers, @@ -56,7 +56,7 @@ export const crudRouter = router({ .string() .min(5) .max(64) - .regex(/^[a-z0-9]*$/, { + .regex(/^[a-z0-9\-]*$/, { message: 'Only lowercase letters and numbers' }) }) @@ -65,6 +65,46 @@ export const crudRouter = router({ return await validateOrgShortcode(ctx.db, input.shortcode); }), + generateOrgShortcode: accountProcedure + .input( + z.object({ + orgName: z.string().min(5) + }) + ) + .query(async ({ ctx, input }) => { + const autoShortcode = input.orgName + .toLowerCase() + .replace(/[^a-z0-9\-]/g, ''); + const existingOrgs = await ctx.db.query.orgs.findMany({ + where: like(orgs.shortcode, `${autoShortcode}%`), + columns: { + shortcode: true + } + }); + + if (existingOrgs.length === 0) { + return { shortcode: autoShortcode.substring(0, 32) }; + } + const existingShortcodeList = existingOrgs.map((org) => org.shortcode); + let currentSuffix = existingShortcodeList.length; + let retries = 0; + let newShortcode = `${autoShortcode.substring(0, 28)}-${currentSuffix}`; + + while (existingShortcodeList.includes(newShortcode)) { + currentSuffix++; + newShortcode = `${autoShortcode.substring(0, 28)}-${currentSuffix}`; + retries++; + if (retries > 30) { + throw new TRPCError({ + code: 'CONFLICT', + message: + 'Failed to generate unique shortcode, please type one manually' + }); + } + } + return { shortcode: newShortcode }; + }), + createNewOrg: accountProcedure .input( z.object({ diff --git a/apps/platform/trpc/routers/userRouter/profileRouter.ts b/apps/platform/trpc/routers/userRouter/profileRouter.ts index 200d693b..2c67c438 100644 --- a/apps/platform/trpc/routers/userRouter/profileRouter.ts +++ b/apps/platform/trpc/routers/userRouter/profileRouter.ts @@ -116,23 +116,22 @@ export const profileRouter = router({ .input( z.object({ profilePublicId: typeIdValidator('orgMemberProfile'), - fName: z.string(), - lName: z.string(), - title: z.string(), - blurb: z.string(), - imageId: z.string().uuid().optional().nullable(), - handle: z.string().min(2).max(20) + name: z.string(), + title: z.string().optional(), + blurb: z.string().optional(), + handle: z.string().min(2).max(20).optional() }) ) .mutation(async ({ ctx, input }) => { const { db, account } = ctx; const accountId = account.id; + const [firstName, ...lastName] = input.name.split(' '); await db .update(orgMemberProfiles) .set({ - firstName: input.fName, - lastName: input.lName, + firstName, + lastName: lastName.join(' '), title: input.title, blurb: input.blurb, handle: input.handle diff --git a/apps/web/src/app/(login)/_components/passkey-login.tsx b/apps/web/src/app/(login)/_components/passkey-login.tsx index a4fde947..a4b8b89f 100644 --- a/apps/web/src/app/(login)/_components/passkey-login.tsx +++ b/apps/web/src/app/(login)/_components/passkey-login.tsx @@ -40,6 +40,7 @@ export function PasskeyLoginButton() { description: 'Redirecting you to create an organization' }); router.push('/join/org'); + return; } toast.success('Sign in successful!', { diff --git a/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx b/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx index 313c2079..3e45fc13 100644 --- a/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx +++ b/apps/web/src/app/[orgShortcode]/settings/user/profile/page.tsx @@ -72,8 +72,7 @@ export default function Page() { const { loading: saveLoading, run: saveProfile } = useLoading(async () => { if (!initData) return; await updateProfileApi.mutateAsync({ - fName: firstNameValue, - lName: lastNameValue, + name: `${firstNameValue} ${lastNameValue}`, blurb: bioValue, title: titleValue, handle: initData.profile.handle ?? '', diff --git a/apps/web/src/app/join/_components/stepper.tsx b/apps/web/src/app/join/_components/stepper.tsx index 13e659e9..3974003c 100644 --- a/apps/web/src/app/join/_components/stepper.tsx +++ b/apps/web/src/app/join/_components/stepper.tsx @@ -1,5 +1,5 @@ -import { Separator } from '@/src/components/shadcn-ui/separator'; import { cn } from '@/src/lib/utils'; +import { Check } from '@phosphor-icons/react'; export default function Stepper({ step, @@ -9,15 +9,25 @@ export default function Stepper({ total: number; }) { return ( -
+
+ {`This is step ${step} of ${total}`} {Array.from({ length: total }).map((_, i) => ( - i + 1 && 'bg-green-9 border-none text-white' + )}> + {step > i + 1 ? ( + + ) : ( + i + 1 )} - /> +
))}
); diff --git a/apps/web/src/app/join/layout.tsx b/apps/web/src/app/join/layout.tsx index a3165b86..ddc5c17b 100644 --- a/apps/web/src/app/join/layout.tsx +++ b/apps/web/src/app/join/layout.tsx @@ -2,12 +2,8 @@ export default function Page({ children }: { children: React.ReactNode }) { return ( -
-
-

Let's Make your

-

UnInbox

- {children} -
+
+ {children}
); } diff --git a/apps/web/src/app/join/org/_components/create-org.tsx b/apps/web/src/app/join/org/_components/create-org.tsx index 4c62cf68..74750ce0 100644 --- a/apps/web/src/app/join/org/_components/create-org.tsx +++ b/apps/web/src/app/join/org/_components/create-org.tsx @@ -1,189 +1,163 @@ 'use client'; -import useLoading from '@/src/hooks/use-loading'; import { platform } from '@/src/lib/trpc'; import { Button } from '@/src/components/shadcn-ui/button'; -import { - Dialog, - DialogContent, - DialogTrigger, - DialogTitle, - DialogClose -} from '@/src/components/shadcn-ui/dialog'; -import { useDebounce } from '@uidotdev/usehooks'; -import { Check, Plus } from '@phosphor-icons/react'; -import { useEffect, useState } from 'react'; +import { IdentificationCard } from '@phosphor-icons/react'; +import { useMemo, useState } from 'react'; import { z } from 'zod'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { Input } from '@/src/components/shadcn-ui/input'; import { cn } from '@/src/lib/utils'; +import { env } from '@/src/env'; +import { EditableText } from '@/src/components/shared/editable-text'; +import { + Tooltip, + TooltipContent, + TooltipTrigger +} from '@/src/components/shadcn-ui/tooltip'; -export default function CreateOrgButton({ - hasInviteCode -}: { - hasInviteCode: boolean; -}) { +export function CreateOrg() { const [orgName, setOrgName] = useState(''); const [orgShortcode, setOrgShortcode] = useState(''); const [customShortcode, setCustomShortcode] = useState(false); const router = useRouter(); - const debouncedOrgShortcode = useDebounce(orgShortcode, 1000); - const checkOrgShortcodeApi = - platform.useUtils().org.crud.checkShortcodeAvailability; - const createOrgApi = platform.org.crud.createNewOrg.useMutation(); - - const { - loading: orgShortcodeDataLoading, - data: orgShortcodeData, - error: orgShortcodeError, - run: checkOrgShortcode - } = useLoading(async (signal) => { - if (!debouncedOrgShortcode) return; - const parsed = z + const [shortcodeValid, shortcodeError] = useMemo(() => { + const { success, error } = z .string() .min(5) .max(64) - .regex(/^[a-z0-9]*$/, { + .regex(/^[a-z0-9\-]*$/, { message: 'Only lowercase letters and numbers' }) - .safeParse(debouncedOrgShortcode); + .safeParse(orgShortcode); + return [ + success, + error ? new Error(error.issues.map((i) => i.message).join(', ')) : null + ]; + }, [orgShortcode]); - if (!parsed.success) { - return { - error: parsed.error.issues[0]?.message ?? null, - available: false - }; - } - return await checkOrgShortcodeApi.fetch( - { shortcode: debouncedOrgShortcode }, - { signal } + const { data, isLoading, error } = + platform.org.crud.checkShortcodeAvailability.useQuery( + { + shortcode: orgShortcode + }, + { + enabled: shortcodeValid && customShortcode + } ); - }); - - const { - loading: createOrgLoading, - error: createOrgError, - run: createOrg - } = useLoading(async () => { - if (!orgShortcodeData?.available) return; - await createOrgApi.mutateAsync({ - orgName, - orgShortcode: debouncedOrgShortcode - }); - toast.success('Organization created successfully.'); - router.push(`/join/profile?org=${debouncedOrgShortcode}`); - }); - useEffect(() => { - if (customShortcode) return; - setOrgShortcode(orgName?.toLowerCase().replace(/[^a-z0-9]/g, '') || ''); - }, [orgName, customShortcode]); + const { refetch: generateShortcode } = + platform.org.crud.generateOrgShortcode.useQuery( + { + orgName + }, + { + enabled: false + } + ); - useEffect(() => { - checkOrgShortcode({ clearData: true, clearError: true }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedOrgShortcode]); + const { mutateAsync: createOrg, isPending } = + platform.org.crud.createNewOrg.useMutation({ + onError: (e) => { + toast.error('An Error Occurred while creating the Org', { + description: e.message + }); + } + }); return ( - { - if (!open) { - setOrgName(''); - setOrgShortcode(''); - setCustomShortcode(false); - } - }}> - - - +
+ { + // If the org name is empty, clear the shortcode + if (orgName.trim().length === 0 && !customShortcode) { + if (orgShortcode) { + setOrgShortcode(''); + } + } + // If the org name is less than 5 characters, do not generate a shortcode + if (customShortcode || orgName.trim().length < 5) return; + const { data, error } = await generateShortcode(); - - Create a new organization -
- -
+ : data?.error ?? 'Shortcode is not available' + : ''} +
+ + ); } diff --git a/apps/web/src/app/join/org/_components/join-org.tsx b/apps/web/src/app/join/org/_components/join-org.tsx index bc720d6a..55c77139 100644 --- a/apps/web/src/app/join/org/_components/join-org.tsx +++ b/apps/web/src/app/join/org/_components/join-org.tsx @@ -1,6 +1,5 @@ 'use client'; -import useLoading from '@/src/hooks/use-loading'; import { platform } from '@/src/lib/trpc'; import { generateAvatarUrl, getInitials } from '@/src/lib/utils'; import { Button } from '@/src/components/shadcn-ui/button'; @@ -9,6 +8,7 @@ import { Dialog, DialogClose, DialogContent, + DialogHeader, DialogTitle, DialogTrigger } from '@/src/components/shadcn-ui/dialog'; @@ -17,12 +17,13 @@ import { AvatarFallback, AvatarImage } from '@/src/components/shadcn-ui/avatar'; -import { Users } from '@phosphor-icons/react'; +import { UsersThree } from '@phosphor-icons/react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { useRouter } from 'next/navigation'; +import { DialogDescription } from '@radix-ui/react-dialog'; -export default function JoinOrgButton({ +export function JoinOrg({ hasInviteCode, inviteCode: initialInviteCode }: { @@ -30,51 +31,35 @@ export default function JoinOrgButton({ inviteCode?: string; }) { const [inviteCode, setInviteCode] = useState(initialInviteCode ?? ''); - const validateInviteCodeApi = - platform.useUtils().org.users.invites.validateInvite; - const joinOrgApi = platform.org.users.invites.redeemInvite.useMutation(); const router = useRouter(); const [modalOpen, setModalOpen] = useState(false); const { data: inviteData, - loading: inviteLoading, - error: inviteError, - run: validateInvite, - clearData: clearInviteData - } = useLoading(async (signal) => { - return await validateInviteCodeApi.fetch( - { - inviteToken: inviteCode - }, - { signal } - ); - }); - + isLoading: inviteLoading, + error: inviteError + } = platform.org.users.invites.validateInvite.useQuery( + { + inviteToken: inviteCode + }, + { + enabled: inviteCode.length === 32 + } + ); const { - loading: joinLoading, + mutateAsync: joinOrg, error: joinError, - run: joinOrg - } = useLoading(async () => { - const { success, orgShortcode } = await joinOrgApi.mutateAsync({ - inviteToken: inviteCode - }); - if (success) { - toast.success(`You have joined ${inviteData?.orgName}!`); - router.push(`/join/profile?org=${orgShortcode}`); - setModalOpen(false); - } else { - throw new Error('Unknown Error Occurred'); + isPending: isJoining + } = platform.org.users.invites.redeemInvite.useMutation({ + onSuccess: ({ success }) => { + if (success) { + toast.success(`You have joined ${inviteData?.orgName}!`); + router.push(`/join/profile?org=${inviteData?.orgShortcode}`); + setModalOpen(false); + } } }); - useEffect(() => { - if (inviteCode && modalOpen) { - validateInvite(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [modalOpen]); - useEffect(() => { if (hasInviteCode) { setModalOpen(true); @@ -99,24 +84,28 @@ export default function JoinOrgButton({ open={modalOpen}> - Join an Organization + + Join an Organization + + Enter the invite code you received to join an organization + +
@@ -157,32 +146,31 @@ export default function JoinOrgButton({
)} - {joinError && !joinLoading && ( + {joinError && !isJoining && (
{joinError.message}
)} - - +
+ + + - +
diff --git a/apps/web/src/app/join/org/page.tsx b/apps/web/src/app/join/org/page.tsx index 6c4eed9d..718327ae 100644 --- a/apps/web/src/app/join/org/page.tsx +++ b/apps/web/src/app/join/org/page.tsx @@ -1,38 +1,57 @@ 'use client'; +import { At } from '@phosphor-icons/react'; import Stepper from '../_components/stepper'; -import CreateOrgButton from './_components/create-org'; -import JoinOrgButton from './_components/join-org'; +import { CreateOrg } from './_components/create-org'; +import { JoinOrg } from './_components/join-org'; import { useCookies } from 'next-client-cookies'; +import Image from 'next/image'; -export default function JoinOrg() { +export default function JoinOrgPage() { const cookies = useCookies(); const inviteCode = cookies.get('un-invite-code'); - const hasInviteCode = !!inviteCode; + const username = cookies.get('un-join-username'); + const hasInviteCode = Boolean(inviteCode); return ( -
-
Setup Your Organization
- +
+
+ UnInbox Logo + +
-
- With an organization you can share conversations, notes and email - identities between members and groups. -
-
- If you're planning on using UnInbox alone, you'll still need - an organization to manage all the settings. -
-
- You can be a member of multiple organizations. +
+ Set up your organization + {/* Username Cookie might not be available if the user is creating a org later, so we conditionally render it */} + {username && ( +
+ + + {username} + +
+ )}
+ + With an organization you can share conversations, notes and email + identities between members and teams. +
- - + diff --git a/apps/web/src/app/join/page.tsx b/apps/web/src/app/join/page.tsx index 19f3bd15..bcd082a7 100644 --- a/apps/web/src/app/join/page.tsx +++ b/apps/web/src/app/join/page.tsx @@ -1,156 +1,152 @@ 'use client'; -import { - Tooltip, - TooltipContent, - TooltipTrigger -} from '@/src/components/shadcn-ui/tooltip'; import { Button } from '@/src/components/shadcn-ui/button'; import { Checkbox } from '@/src/components/shadcn-ui/checkbox'; import Stepper from './_components/stepper'; -import { Check, Plus, Info } from '@phosphor-icons/react'; +import { At, Check, Plus, SpinnerGap } from '@phosphor-icons/react'; import { useDebounce } from '@uidotdev/usehooks'; import { platform } from '@/src/lib/trpc'; -import { useState, useEffect } from 'react'; -import { toast } from 'sonner'; +import { useEffect, useMemo, useState } from 'react'; import { zodSchemas } from '@u22n/utils/zodSchemas'; -import { useCookies } from 'next-client-cookies'; import { useRouter } from 'next/navigation'; -import Link from 'next/link'; -import useLoading from '@/src/hooks/use-loading'; import { Input } from '@/src/components/shadcn-ui/input'; -import { cn } from '@/src/lib/utils'; import { Label } from '@/src/components/shadcn-ui/label'; +import Image from 'next/image'; +import { useCookies } from 'next-client-cookies'; import { datePlus } from '@u22n/utils/ms'; export default function Page() { - const [username, setUsername] = useState(); - const [agree, setAgree] = useState(false); - const cookies = useCookies(); + const [username, setUsername] = useState(''); + const [agree, setAgree] = useState(true); const router = useRouter(); - const checkUsernameApi = - platform.useUtils().auth.signup.checkUsernameAvailability; const debouncedUsername = useDebounce(username, 1000); + const cookies = useCookies(); - const { - loading: usernameLoading, - data: usernameData, - error: usernameError, - run: checkUsername - } = useLoading(async (signal) => { - if (!debouncedUsername) return; - const parsed = zodSchemas.username().safeParse(debouncedUsername); - if (!parsed.success) { - return { - error: parsed.error.issues[0]?.message ?? null, - available: false - }; - } - return await checkUsernameApi.fetch( - { username: debouncedUsername }, - { signal } - ); - }); - - useEffect(() => { - if (typeof debouncedUsername === 'undefined') return; - checkUsername({ clearData: true, clearError: true }); - // eslint-disable-next-line react-hooks/exhaustive-deps + const [validUsername, usernameError] = useMemo(() => { + const { success, error } = zodSchemas + .username() + .safeParse(debouncedUsername); + return [ + success, + error ? new Error(error.issues.map((i) => i.message).join(', ')) : null + ]; }, [debouncedUsername]); - useEffect(() => { - if (usernameError) { - toast.error(usernameError.message); - } - }, [usernameError]); + const { data, isLoading, error } = + platform.auth.signup.checkUsernameAvailability.useQuery( + { + username: debouncedUsername + }, + { + enabled: validUsername + } + ); - function nextStep() { - if (!username) return; - cookies.set('un-join-username', username, { - expires: datePlus('15 minutes') - }); - router.push('/join/secure'); - } + // Load username from cookie if available + useEffect(() => { + const cookieUsername = cookies.get('un-join-username'); + if (cookieUsername) setUsername(cookieUsername); + }, [cookies]); return ( -
- - Choose Your Username - - -
- - This will be your username across the whole Un ecosystem. - - - It's yours personally and can join as many organizations as you - want. +
+
+ UnInbox Logo + +
+
+
+ Choose your username +
+ + + This will be your username across the whole Un ecosystem. It's + yours personally and can join as many organizations as you want.
-
- Username -
+
+
setUsername(e.target.value)} + leadingSlot={At} + trailingSlot={() => { + if (!debouncedUsername) return null; + return isLoading ? ( + + ) : usernameError ?? error ? ( + + ) : data && !isLoading ? ( + data.available ? ( + + ) : ( + + ) + ) : null; + }} + hint={ + debouncedUsername + ? isLoading + ? { message: 'Checking username...' } + : usernameError + ? { + type: 'error', + message: usernameError.message + } + : data && !isLoading + ? { + type: data.available ? 'success' : 'error', + message: data.available + ? 'Looks good!' + : data.error ?? 'Username is not available' + } + : error + ? { + type: 'error', + message: error.message + } + : { message: '' } + : { message: '' } + } /> - - - - - - Username can only contain letters, numbers, and underscores. - -
- {!usernameData && usernameLoading && ( -
- Checking... -
- )} - - {usernameData && !usernameLoading && ( -
- {usernameData.available ? ( - - ) : ( - - )} -
- {usernameData.available ? 'Looks good!' : usernameData.error} -
-
- )} - - {usernameError && !usernameLoading && ( -
- {usernameError.message} -
- )} -
- - - - + +
); } diff --git a/apps/web/src/app/join/profile/_components/profile-card.tsx b/apps/web/src/app/join/profile/_components/profile-card.tsx index 33805ec0..56599e93 100644 --- a/apps/web/src/app/join/profile/_components/profile-card.tsx +++ b/apps/web/src/app/join/profile/_components/profile-card.tsx @@ -1,17 +1,16 @@ 'use client'; import { Button } from '@/src/components/shadcn-ui/button'; -import { AvatarModal } from '@/src/components/shared/avatar-modal'; import { type RouterOutputs, platform } from '@/src/lib/trpc'; import Stepper from '../../_components/stepper'; -import { useEffect, useState } from 'react'; -import { cn, generateAvatarUrl } from '@/src/lib/utils'; -import useLoading from '@/src/hooks/use-loading'; -import { Camera, Checks, SkipForward } from '@phosphor-icons/react'; +import { useEffect, useMemo, useState } from 'react'; +import { User } from '@phosphor-icons/react'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; -import useAwaitableModal from '@/src/hooks/use-awaitable-modal'; import { Input } from '@/src/components/shadcn-ui/input'; +import Image from 'next/image'; +import { useAvatarUploader } from '@/src/hooks/use-avatar-uploader'; +import { generateAvatarUrl } from '@/src/lib/utils'; type ProfileCardProps = { orgData: RouterOutputs['account']['profile']['getOrgMemberProfile']; @@ -19,146 +18,445 @@ type ProfileCardProps = { }; export function ProfileCard({ orgData, wasInvited }: ProfileCardProps) { - const [AvatarModalRoot, openAvatarModal] = useAwaitableModal(AvatarModal, { - publicId: orgData.profile.publicId - }); - const [avatarUrl, setAvatarUrl] = useState(null); - const [firstNameValue, setFirstNameValue] = useState( - orgData.profile.firstName ?? orgData.profile.handle ?? '' + const [name, setName] = useState( + `${orgData.profile.firstName ?? orgData.profile.handle ?? ''} ${orgData.profile.lastName ?? ''}` ); - const [lastNameValue, setLastNameValue] = useState( - orgData.profile.lastName ?? '' - ); - const router = useRouter(); const query = useSearchParams(); const orgShortCode = query.get('org'); + const { upload, uploadResponse, uploading, error, progress } = + useAvatarUploader(); - const { - error: avatarError, - loading: avatarLoading, - run: openModal - } = useLoading(async () => { - const avatarTimestamp = new Date(await openAvatarModal({})); - setAvatarUrl( - generateAvatarUrl({ - publicId: orgData.profile.publicId, - avatarTimestamp, - size: '5xl' - }) - ); - }); + const { mutateAsync: updateProfile, isPending } = + platform.account.profile.updateOrgMemberProfile.useMutation({ + onError: (error) => { + toast.error("Couldn't update profile", { description: error.message }); + } + }); - const updateProfileApi = - platform.account.profile.updateOrgMemberProfile.useMutation(); - const { - loading: saveLoading, - error: saveError, - run: saveProfile - } = useLoading(async () => { - await updateProfileApi.mutateAsync({ - fName: firstNameValue, - lName: lastNameValue, - blurb: orgData.profile.blurb ?? '', - handle: orgData.profile.handle ?? '', - profilePublicId: orgData.profile.publicId, - title: orgData.profile.title ?? '' + const avatarUrl = useMemo(() => { + if (!orgData.profile.avatarTimestamp && !uploadResponse) { + return null; + } + return generateAvatarUrl({ + publicId: orgData.profile.publicId, + avatarTimestamp: + uploadResponse?.avatarTimestamp ?? orgData.profile.avatarTimestamp }); - }); + }, [ + orgData.profile.avatarTimestamp, + orgData.profile.publicId, + uploadResponse + ]); useEffect(() => { - if (saveError) { - toast.error(saveError.message); + if (error) { + toast.error("Couldn't upload avatar", { description: error.message }); } - }, [saveError]); - - const handleSkip = () => { - router.push(`/${orgShortCode}`); - }; - const handleSave = () => { - saveProfile({ clearData: true, clearError: true }); - router.push(`/${orgShortCode}`); - }; + }, [error]); return ( -
-
- {wasInvited ? 'Got time for a profile?' : 'Edit your profile'} +
+
+ UnInbox Logo +
- -
-
+
+
+ {wasInvited ? 'Got time for a profile?' : 'Complete your profile'} +
+ + {wasInvited ? 'This profile has been set by the person who invited you. You can have a separate profile for each organization you join.' : 'You can have a different profile for each organization you join, lets start with your first one!'} -
-
Skip this step if you like
+
-
- - {avatarError && ( -
{avatarError.message}
- )} - -
- -