From f1de05e4de4320cf2244a64acc110789348520d1 Mon Sep 17 00:00:00 2001 From: Lakhan Baheti <94619783+1akhanBaheti@users.noreply.github.com> Date: Mon, 20 Nov 2023 19:31:19 +0530 Subject: [PATCH] chore: onboarding (#2790) * style: onboarding light version * style: dark mode * fix: onboarding gradient * refactor: imports * chore: add use case field in users api * feat: delete account * fix: delete modal points alignment * feat: usecase in profile * fix: build error * fix: typos & hardcoded strings --------- Co-authored-by: pablohashescobar --- apiserver/plane/app/serializers/user.py | 1 + .../tailwind-config-custom/tailwind.config.js | 22 + .../account/delete-account-modal.tsx | 138 ++++ web/components/account/email-code-form.tsx | 206 +++--- .../account/github-login-button.tsx | 8 +- web/components/account/google-login.tsx | 2 +- web/components/account/sidebar.tsx | 269 ++++++++ web/components/account/step-indicator.tsx | 21 + .../core/modals/image-upload-modal.tsx | 12 +- web/components/onboarding/invitations.tsx | 172 +++++ web/components/onboarding/invite-members.tsx | 163 +++-- web/components/onboarding/join-workspaces.tsx | 182 ++--- web/components/onboarding/user-details.tsx | 259 +++---- web/components/onboarding/workspace.tsx | 183 +++-- web/components/page-views/signin.tsx | 141 ++-- web/components/workspace/sidebar-dropdown.tsx | 2 +- web/pages/onboarding/index.tsx | 183 ++--- web/public/onboarding/onboarding-issues.svg | 592 ++++++++++++++++ web/public/onboarding/sign-in.svg | 649 ++++++++++++++++++ web/public/users/user-1.png | Bin 0 -> 4193 bytes web/public/users/user-2.png | Bin 0 -> 3781 bytes web/services/user.service.ts | 9 + web/services/workspace.service.ts | 2 +- web/styles/globals.css | 37 + web/types/users.d.ts | 1 + 25 files changed, 2674 insertions(+), 580 deletions(-) create mode 100644 web/components/account/delete-account-modal.tsx create mode 100644 web/components/account/sidebar.tsx create mode 100644 web/components/account/step-indicator.tsx create mode 100644 web/components/onboarding/invitations.tsx create mode 100644 web/public/onboarding/onboarding-issues.svg create mode 100644 web/public/onboarding/sign-in.svg create mode 100644 web/public/users/user-1.png create mode 100644 web/public/users/user-2.png diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 687993fcc..e073a0a52 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -59,6 +59,7 @@ class UserMeSerializer(BaseSerializer): "username", "theme", "last_workspace_id", + "use_case", ] read_only_fields = fields diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 5aef561e9..4c7ebf963 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -176,6 +176,24 @@ module.exports = { }, backdrop: "rgba(0, 0, 0, 0.25)", }, + onboarding: { + background: { + 100: convertToRGB("--color-onboarding-background-100"), + 200: convertToRGB("--color-onboarding-background-200"), + 300: convertToRGB("--color-onboarding-background-300"), + 400: convertToRGB("--color-onboarding-background-400"), + }, + text: { + 100: convertToRGB("--color-onboarding-text-100"), + 200: convertToRGB("--color-onboarding-text-200"), + 300: convertToRGB("--color-onboarding-text-300"), + 400: convertToRGB("--color-onboarding-text-400"), + }, + border: { + 100: convertToRGB("--color-onboarding-border-100"), + 200: convertToRGB("--color-onboarding-border-200"), + }, + }, }, keyframes: { leftToaster: { @@ -353,6 +371,10 @@ module.exports = { 80: "18rem", 96: "21.6rem", }, + backgroundImage: { + "onboarding-gradient-primary": "var( --gradient-onboarding-primary)", + "onboarding-gradient-secondary": "var( --gradient-onboarding-secondary)", + }, }, fontFamily: { custom: ["Inter", "sans-serif"], diff --git a/web/components/account/delete-account-modal.tsx b/web/components/account/delete-account-modal.tsx new file mode 100644 index 000000000..844c3837e --- /dev/null +++ b/web/components/account/delete-account-modal.tsx @@ -0,0 +1,138 @@ +// react +import React, { useState } from "react"; +// next +import { useRouter } from "next/router"; +// components +import { Button } from "@plane/ui"; +// hooks +import useToast from "hooks/use-toast"; +// services +import { AuthService } from "services/auth.service"; +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// icons +import { AlertTriangle } from "lucide-react"; +import { UserService } from "services/user.service"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +const authService = new AuthService(); +const userService = new UserService(); + +const DeleteAccountModal: React.FC = (props) => { + const { isOpen, onClose } = props; + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const router = useRouter(); + const { setToastAlert } = useToast(); + + const handleSignOut = async () => { + await authService + .signOut() + .then(() => { + router.push("/"); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ); + }; + + const handleDeleteAccount = async () => { + setIsDeleteLoading(true); + await userService + .deleteAccount() + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Account deleted successfully.", + }); + router.push("/"); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: err?.data?.error, + }) + ); + setIsDeleteLoading(false); + }; + + const handleClose = () => { + onClose(); + }; + + return ( + + + +
+ + +
+
+ + +
+
+
+
+
+ + Not the right workspace? + +
+ +
+
    +
  • Delete this account if you have another and won’t use this account.
  • +
  • Switch to another account if you’d like to come back to this account another time.
  • +
+
+
+
+
+ + Switch account + + +
+
+
+
+
+
+
+ ); +}; + +export default DeleteAccountModal; diff --git a/web/components/account/email-code-form.tsx b/web/components/account/email-code-form.tsx index 05bc89abd..560306c3b 100644 --- a/web/components/account/email-code-form.tsx +++ b/web/components/account/email-code-form.tsx @@ -7,6 +7,9 @@ import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; import useTimer from "hooks/use-timer"; +// icons +import { XCircle } from "lucide-react"; +import { useTheme } from "next-themes"; // types type EmailCodeFormValues = { @@ -23,6 +26,7 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { const [isCodeResending, setIsCodeResending] = useState(false); const [errorResendingCode, setErrorResendingCode] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [sentEmail, setSentEmail] = useState(""); const { setToastAlert } = useToast(); const { timer: resendCodeTimer, setTimer: setResendCodeTimer } = useTimer(); @@ -52,6 +56,8 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { await authService .emailCode({ email }) .then((res) => { + console.log(res); + setSentEmail(email); setValue("key", res.key); setCodeSent(true); }) @@ -88,10 +94,6 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { const emailOld = getValues("email"); - useEffect(() => { - setErrorResendingCode(false); - }, [emailOld]); - useEffect(() => { const submitForm = (e: KeyboardEvent) => { if (!codeSent && e.key === "Enter") { @@ -99,29 +101,54 @@ export const EmailCodeForm = ({ handleSignIn }: any) => { handleSubmit(onSubmit)().then(() => { setResendCodeTimer(30); }); + } else if ( + codeSent && + sentEmail != getValues("email") && + getValues("email").length > 0 && + (e.key === "Enter" || e.key === "Tab") + ) { + e.preventDefault(); + console.log("resend"); + onSubmit({ email: getValues("email") }).then(() => { + setCodeResent(true); + }); } }; - if (!codeSent) { - window.addEventListener("keydown", submitForm); - } + window.addEventListener("keydown", submitForm); return () => { window.removeEventListener("keydown", submitForm); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [handleSubmit, codeSent]); + }, [handleSubmit, codeSent, sentEmail]); return ( <> - {(codeSent || codeResent) && ( -

- We have sent the sign in code. -
- Please check your inbox at {watch("email")} -

+ {codeSent || codeResent ? ( + <> +

+ Moving to the runway +

+
+

Paste the code you got at

+ {sentEmail} + below. +
+ + ) : ( + <> +

+ Let’s get you prepped! +

+

+ This whole thing will take less than two minutes. +

+

Promise!

+ )} -
+ +
{ ) || "Email address is not valid", }} render={({ field: { value, onChange, ref } }) => ( - +
+ + {value.length > 0 && ( + setValue("email", "")} + /> + )} +
)} />
{codeSent && ( <> - ( - - )} - /> - + +
+ ( + + )} + /> +
)} {codeSent ? ( - +
+ {" "} + +
+

+ When you click the button above, you agree with our{" "} + + terms and conditions of service. + {" "} +

+
+
) : ( )} diff --git a/web/components/account/github-login-button.tsx b/web/components/account/github-login-button.tsx index fc140f632..eddc53af0 100644 --- a/web/components/account/github-login-button.tsx +++ b/web/components/account/github-login-button.tsx @@ -1,4 +1,6 @@ +// react import { useEffect, useState, FC } from "react"; +// next import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; @@ -41,14 +43,16 @@ export const GithubLoginButton: FC = (props) => { - diff --git a/web/components/account/google-login.tsx b/web/components/account/google-login.tsx index 768dcd6d5..7f07d4bbe 100644 --- a/web/components/account/google-login.tsx +++ b/web/components/account/google-login.tsx @@ -29,7 +29,7 @@ export const GoogleLoginButton: FC = (props) => { theme: "outline", size: "large", logo_alignment: "center", - width: 360, + width: 384, text: "signin_with", } as GsiButtonConfiguration // customization attributes ); diff --git a/web/components/account/sidebar.tsx b/web/components/account/sidebar.tsx new file mode 100644 index 000000000..284719176 --- /dev/null +++ b/web/components/account/sidebar.tsx @@ -0,0 +1,269 @@ +import React, { useEffect } from "react"; +import { Avatar, DiceIcon, PhotoFilterIcon } from "@plane/ui"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// react-hook-form +import { Control, Controller, UseFormSetValue, UseFormWatch } from "react-hook-form"; +// types +import { IWorkspace } from "types"; +// icons +import { + BarChart2, + Briefcase, + CheckCircle, + ChevronDown, + ContrastIcon, + FileText, + LayersIcon, + LayoutGrid, + PenSquare, + Search, + Settings, +} from "lucide-react"; + +const workspaceLinks = [ + { + Icon: LayoutGrid, + name: "Dashboard", + }, + { + Icon: BarChart2, + name: "Analytics", + }, + { + Icon: Briefcase, + name: "Projects", + }, + { + Icon: CheckCircle, + name: "All Issues", + }, + { + Icon: CheckCircle, + name: "Notifications", + }, +]; + +const projectLinks = [ + { + name: "Issues", + Icon: LayersIcon, + }, + { + name: "Cycles", + + Icon: ContrastIcon, + }, + { + name: "Modules", + Icon: DiceIcon, + }, + { + name: "Views", + + Icon: PhotoFilterIcon, + }, + { + name: "Pages", + + Icon: FileText, + }, + { + name: "Settings", + + Icon: Settings, + }, +]; + +type Props = { + workspaceName: string; + showProject: boolean; + control?: Control; + setValue?: UseFormSetValue; + watch?: UseFormWatch; +}; +var timer: number = 0; +var lastWorkspaceName: string = ""; +const DummySidebar: React.FC = (props) => { + const { workspaceName, showProject, control, setValue, watch } = props; + const { workspace: workspaceStore, user: userStore } = useMobxStore(); + const workspace = workspaceStore.workspaces ? workspaceStore.workspaces[0] : null; + + const handleZoomWorkspace = (value: string) => { + // console.log(lastWorkspaceName,value); + if (lastWorkspaceName === value) return; + lastWorkspaceName = value; + if (timer > 0) { + timer += 2; + timer = Math.min(timer, 4); + } else { + timer = 2; + timer = Math.min(timer, 4); + const interval = setInterval(() => { + if (timer < 0) { + setValue!("name", lastWorkspaceName); + clearInterval(interval); + } + console.log("timer", timer); + timer--; + }, 1000); + } + }; + + useEffect(() => { + if (watch) { + watch(); + } + }); + + return ( +
+
+ {control && setValue ? ( + { + if (value.length > 0) { + handleZoomWorkspace(value); + } + return timer > 0 ? ( +
+
+
+ 0 ? value[0].toLocaleUpperCase() : "N"} + src={""} + size={30} + shape="square" + fallbackBackgroundColor="black" + className="!text-base" + /> +
+ + {value} +
+
+ ) : ( +
+
+ 0 ? value : workspace ? workspace.name[0].toLocaleUpperCase() : "N"} + src={""} + size={24} + shape="square" + className="!text-base" + /> +
+
+

{workspaceName}

+ +
+
+ +
+
+ ); + }} + /> + ) : ( +
+
+ +
+
+

{workspaceName}

+ +
+
+ +
+
+ )} +
+ +
+
+
+
+ + {New Issue} +
+
+ +
+ +
+
+ {workspaceLinks.map((link) => ( + +
+ {} + {link.name} +
+
+ ))} +
+ + {showProject && ( +
+

Projects

+ +
+ {" "} +
+ Plane web + +
+ {projectLinks.map((link) => ( + +
+ {} + {link.name} +
+
+ ))} +
+
+ )} +
+ ); +}; + +export default DummySidebar; diff --git a/web/components/account/step-indicator.tsx b/web/components/account/step-indicator.tsx new file mode 100644 index 000000000..75957b145 --- /dev/null +++ b/web/components/account/step-indicator.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +const OnboardingStepIndicator = ({ step }: { step: number }) => ( +
+
+
= 2 ? "bg-custom-primary-100" : "bg-onboarding-background-100"}`} /> +
= 2 ? "bg-custom-primary-100 h-4 w-4" : " h-3 w-3 bg-onboarding-background-100" + }`} + /> +
= 3 ? "bg-custom-primary-100" : "bg-onboarding-background-100"}`} /> +
= 3 ? "bg-custom-primary-100 h-4 w-4" : "h-3 w-3 bg-onboarding-background-100" + }`} + /> +
+); + +export default OnboardingStepIndicator; diff --git a/web/components/core/modals/image-upload-modal.tsx b/web/components/core/modals/image-upload-modal.tsx index 5640d62f6..a879b4705 100644 --- a/web/components/core/modals/image-upload-modal.tsx +++ b/web/components/core/modals/image-upload-modal.tsx @@ -50,10 +50,8 @@ export const ImageUploadModal: React.FC = observer((props) => { }); const handleSubmit = async () => { + if (!image || (!workspaceSlug && router.pathname != "/onboarding")) return; setIsImageUploading(true); - - if (!image || !workspaceSlug) return; - const formData = new FormData(); formData.append("asset", image); formData.append("attributes", JSON.stringify({})); @@ -183,7 +181,13 @@ export const ImageUploadModal: React.FC = observer((props) => { -
diff --git a/web/components/onboarding/invitations.tsx b/web/components/onboarding/invitations.tsx new file mode 100644 index 000000000..c5a733ae4 --- /dev/null +++ b/web/components/onboarding/invitations.tsx @@ -0,0 +1,172 @@ +// react +import React, { useState } from "react"; +// components +import { Button, Loader } from "@plane/ui"; + +// helpers +import { truncateText } from "helpers/string.helper"; +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +// services +import { WorkspaceService } from "services/workspace.service"; +// swr +import useSWR, { mutate } from "swr"; +// contants +import { USER_WORKSPACES, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; +import { ROLE } from "constants/workspace"; +// types +import { IWorkspaceMemberInvitation } from "types"; +// icons +import { CheckCircle2, Search } from "lucide-react"; +import { trackEvent } from "helpers/event-tracker.helper"; + +type Props = { + handleNextStep: () => void; + setTryDiffAccount: () => void; +}; +const workspaceService = new WorkspaceService(); + +const Invitations: React.FC = (props) => { + const { handleNextStep, setTryDiffAccount } = props; + const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); + const [invitationsRespond, setInvitationsRespond] = useState([]); + + const { + workspace: workspaceStore, + user: { currentUser, updateCurrentUser }, + } = useMobxStore(); + + const { + data: invitations, + mutate: mutateInvitations, + isLoading, + } = useSWR(USER_WORKSPACE_INVITATIONS, () => workspaceService.userWorkspaceInvitations()); + + const handleInvitation = (workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw") => { + if (action === "accepted") { + setInvitationsRespond((prevData) => [...prevData, workspace_invitation.id]); + } else if (action === "withdraw") { + setInvitationsRespond((prevData) => prevData.filter((item: string) => item !== workspace_invitation.id)); + } + }; + + const updateLastWorkspace = async () => { + if (!workspaceStore.workspaces) return; + await updateCurrentUser({ + last_workspace_id: workspaceStore.workspaces[0].id, + }); + }; + + const submitInvitations = async () => { + if (invitationsRespond.length <= 0) return; + + setIsJoiningWorkspaces(true); + + await workspaceService + .joinWorkspaces({ invitations: invitationsRespond }) + .then(async (res) => { + trackEvent("WORKSPACE_USER_INVITE_ACCEPT", res); + await mutateInvitations(); + await workspaceStore.fetchWorkspaces(); + await mutate(USER_WORKSPACES); + await updateLastWorkspace(); + await handleNextStep(); + }) + .finally(() => setIsJoiningWorkspaces(false)); + }; + + return invitations && invitations.length > 0 ? ( +
+
+

Choose a workspace to join

+
+ {invitations && + invitations.length > 0 && + invitations.map((invitation) => { + const isSelected = invitationsRespond.includes(invitation.id); + return ( +
handleInvitation(invitation, isSelected ? "withdraw" : "accepted")} + > +
+
+ {invitation.workspace.logo && invitation.workspace.logo !== "" ? ( + {invitation.workspace.name} + ) : ( + + {invitation.workspace.name[0]} + + )} +
+
+
+
{truncateText(invitation.workspace.name, 30)}
+

{ROLE[invitation.role]}

+
+ + + +
+ ); + })} +
+ + +
+
+
+ + Don't see your workspace? +
+ +
+
+ Try a different email address +
+

+ Your right e-mail address could be from a Google or GitHub login. +

+
+
+
+ ) : ( + + ); +}; + +const EmptyInvitation = ({ email }: { email: string }) => ( +
+

Is your team already on Plane?

+

+ We couldn’t find any existing workspaces for the email address {email} +

+
{}} + > + Try a different email address +
+

+ Your right e-mail address could be from a Google or GitHub login. +

+
+); + +export default Invitations; diff --git a/web/components/onboarding/invite-members.tsx b/web/components/onboarding/invite-members.tsx index fa639af72..db7e5d0ba 100644 --- a/web/components/onboarding/invite-members.tsx +++ b/web/components/onboarding/invite-members.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; - +// next +import Image from "next/image"; // headless ui import { Listbox, Transition } from "@headlessui/react"; // react-hook-form @@ -10,14 +11,19 @@ import { WorkspaceService } from "services/workspace.service"; import useToast from "hooks/use-toast"; // ui import { Button, Input } from "@plane/ui"; +// components +import OnboardingStepIndicator from "components/account/step-indicator"; // hooks import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; // icons -import { Check, ChevronDown, Plus, X } from "lucide-react"; +import { Check, ChevronDown, Plus, User2, X, XCircle } from "lucide-react"; // types import { IUser, IWorkspace, TOnboardingSteps, TUserWorkspaceRole } from "types"; // constants import { ROLE } from "constants/workspace"; +// assets +import user1 from "public/users/user-1.png"; +import user2 from "public/users/user-2.png"; type Props = { finishOnboarding: () => Promise; @@ -59,7 +65,7 @@ const InviteMemberForm: React.FC = (props) => { return (
-
+
= (props) => { ref={ref} hasError={Boolean(errors.emails?.[index]?.email)} placeholder="Enter their email..." - className="text-xs sm:text-sm w-full" + className="text-xs sm:text-sm w-full h-12 placeholder:text-onboarding-text-400 border-onboarding-border-100" /> )} />
-
+
= (props) => { type="button" ref={buttonRef} onClick={() => setIsDropdownOpen((prev) => !prev)} - className="flex items-center px-2.5 py-2 text-xs justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none" + className="flex items-center px-2.5 h-11 py-2 text-xs justify-between gap-1 w-full rounded-md duration-300" > - {ROLE[value]} -