From a1b09fcbc6fd37bb6a29132bc4ee88500e71ef8c Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Wed, 12 Jul 2023 19:55:08 +0530 Subject: [PATCH] style: onboarding screens (#1412) * style: new onboarding screens * chore: onboarding tour screens * fix: build error * fix: build errors * style: default layout background * chor: update user auth hook logic, style: new onboarding screens * fix: component structure * chore: tab responsiveness added * fix: redirection logic * style: welcome screens responsiveness * chore: update workspace url input field * style: mobile responsiveness added * chore: complete onboarding workflow * style: create workspace page design update * style: workspace invitations page design update * chore: update steps logic * fix: step change logic * style: tour steps --- .../core/spreadsheet-view/single-issue.tsx | 4 +- apps/app/components/onboarding/index.ts | 4 +- .../components/onboarding/invite-members.tsx | 240 ++++++++++++---- .../components/onboarding/join-workspaces.tsx | 155 +++++++++++ .../components/onboarding/onboarding-card.tsx | 29 -- .../components/onboarding/onboarding-logo.tsx | 29 -- apps/app/components/onboarding/tour/index.ts | 2 + apps/app/components/onboarding/tour/root.tsx | 157 +++++++++++ .../components/onboarding/tour/sidebar.tsx | 70 +++++ .../components/onboarding/user-details.tsx | 197 +++++++------ apps/app/components/onboarding/workspace.tsx | 253 ++--------------- .../project/send-project-invitation-modal.tsx | 2 +- .../components/ui/custom-search-select.tsx | 2 +- .../workspace/create-workspace-form.tsx | 171 ++++++------ apps/app/constants/workspace.ts | 57 +--- apps/app/hooks/use-workspaces.tsx | 14 +- apps/app/layouts/default-layout/index.tsx | 2 +- apps/app/pages/[workspaceSlug]/index.tsx | 26 +- .../pages/[workspaceSlug]/settings/index.tsx | 16 +- apps/app/pages/create-workspace.tsx | 112 +++++--- apps/app/pages/index.tsx | 2 +- apps/app/pages/invitations.tsx | 213 +++++++------- apps/app/pages/onboarding.tsx | 262 +++++++++++------- apps/app/pages/reset-password.tsx | 2 +- apps/app/pages/sign-up.tsx | 2 +- apps/app/public/onboarding/command-menu.svg | 7 - apps/app/public/onboarding/cycle.svg | 43 --- apps/app/public/onboarding/cycles.svg | 43 +++ apps/app/public/onboarding/issue.svg | 43 --- apps/app/public/onboarding/issues.svg | 43 +++ apps/app/public/onboarding/logo.svg | 6 - apps/app/public/onboarding/module.svg | 48 ---- apps/app/public/onboarding/modules.svg | 52 ++++ apps/app/public/onboarding/pages.svg | 43 +++ apps/app/public/onboarding/views.svg | 43 +++ apps/app/public/onboarding/welcome.svg | 5 - .../black-horizontal-with-blue-logo.svg | 17 ++ .../blue-without-text.png} | Bin .../white-horizontal-with-blue-logo.svg | 17 ++ .../public/plane-logos/white-horizontal.svg | 17 ++ apps/app/public/sign-up-sideimg.svg | 57 ---- apps/app/services/track-event.service.ts | 17 ++ apps/app/services/user.service.ts | 19 ++ apps/app/services/workspace.service.ts | 3 +- apps/app/styles/globals.css | 20 -- apps/app/types/users.d.ts | 50 ++-- apps/app/types/workspace.d.ts | 6 +- 47 files changed, 1542 insertions(+), 1080 deletions(-) create mode 100644 apps/app/components/onboarding/join-workspaces.tsx delete mode 100644 apps/app/components/onboarding/onboarding-card.tsx delete mode 100644 apps/app/components/onboarding/onboarding-logo.tsx create mode 100644 apps/app/components/onboarding/tour/index.ts create mode 100644 apps/app/components/onboarding/tour/root.tsx create mode 100644 apps/app/components/onboarding/tour/sidebar.tsx delete mode 100644 apps/app/public/onboarding/command-menu.svg delete mode 100644 apps/app/public/onboarding/cycle.svg create mode 100644 apps/app/public/onboarding/cycles.svg delete mode 100644 apps/app/public/onboarding/issue.svg create mode 100644 apps/app/public/onboarding/issues.svg delete mode 100644 apps/app/public/onboarding/logo.svg delete mode 100644 apps/app/public/onboarding/module.svg create mode 100644 apps/app/public/onboarding/modules.svg create mode 100644 apps/app/public/onboarding/pages.svg create mode 100644 apps/app/public/onboarding/views.svg delete mode 100644 apps/app/public/onboarding/welcome.svg create mode 100644 apps/app/public/plane-logos/black-horizontal-with-blue-logo.svg rename apps/app/public/{logo.png => plane-logos/blue-without-text.png} (100%) create mode 100644 apps/app/public/plane-logos/white-horizontal-with-blue-logo.svg create mode 100644 apps/app/public/plane-logos/white-horizontal.svg delete mode 100644 apps/app/public/sign-up-sideimg.svg diff --git a/apps/app/components/core/spreadsheet-view/single-issue.tsx b/apps/app/components/core/spreadsheet-view/single-issue.tsx index 0bb45371b..f57007e2c 100644 --- a/apps/app/components/core/spreadsheet-view/single-issue.tsx +++ b/apps/app/components/core/spreadsheet-view/single-issue.tsx @@ -348,12 +348,12 @@ export const SingleSpreadsheetIssue: React.FC = ({ )} {properties.created_on && ( -
+
{renderLongDetailDateFormat(issue.created_at)}
)} {properties.updated_on && ( -
+
{renderLongDetailDateFormat(issue.updated_at)}
)} diff --git a/apps/app/components/onboarding/index.ts b/apps/app/components/onboarding/index.ts index af0951109..f19eb9612 100644 --- a/apps/app/components/onboarding/index.ts +++ b/apps/app/components/onboarding/index.ts @@ -1,5 +1,5 @@ +export * from "./tour"; export * from "./invite-members"; -export * from "./onboarding-card"; +export * from "./join-workspaces"; export * from "./user-details"; export * from "./workspace"; -export * from "./onboarding-logo"; diff --git a/apps/app/components/onboarding/invite-members.tsx b/apps/app/components/onboarding/invite-members.tsx index 0390723b6..99a7021ab 100644 --- a/apps/app/components/onboarding/invite-members.tsx +++ b/apps/app/components/onboarding/invite-members.tsx @@ -1,87 +1,215 @@ -// types -import { useForm } from "react-hook-form"; -import useToast from "hooks/use-toast"; +import React, { useEffect } from "react"; + +import useSWR, { mutate } from "swr"; + +// react-hook-form +import { Controller, useFieldArray, useForm } from "react-hook-form"; +// services import workspaceService from "services/workspace.service"; -import { ICurrentUserResponse, IUser } from "types"; -// ui components -import { MultiInput, PrimaryButton, SecondaryButton } from "components/ui"; +import userService from "services/user.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; +// types +import { ICurrentUserResponse, IWorkspace, OnboardingSteps } from "types"; +// fetch-keys +import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; +// constants +import { ROLE } from "constants/workspace"; type Props = { - setStep: React.Dispatch>; - workspace: any; + workspace: IWorkspace | undefined; user: ICurrentUserResponse | undefined; + stepChange: (steps: Partial) => Promise; }; -export const InviteMembers: React.FC = ({ setStep, workspace, user }) => { +type EmailRole = { + email: string; + role: 5 | 10 | 15 | 20; +}; + +type FormValues = { + emails: EmailRole[]; +}; + +export const InviteMembers: React.FC = ({ workspace, user, stepChange }) => { const { setToastAlert } = useToast(); const { - setValue, - watch, + control, handleSubmit, - formState: { isSubmitting }, - } = useForm(); + formState: { isSubmitting, errors }, + } = useForm(); + + const { fields, append, remove } = useFieldArray({ + control, + name: "emails", + }); + + const { data: invitations } = useSWR(USER_WORKSPACE_INVITATIONS, () => + workspaceService.userWorkspaceInvitations() + ); + + const nextStep = async () => { + if (!user || !invitations) return; + + const payload: Partial = { + workspace_invite: true, + }; + + // update onboarding status from this step if no invitations are present + if (invitations.length === 0) { + payload.workspace_join = true; + + mutate( + CURRENT_USER, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + is_onboarded: true, + }; + }, + false + ); + + await userService.updateUserOnBoard({ userRole: user.role }, user); + } + + await stepChange(payload); + }; + + const onSubmit = async (formData: FormValues) => { + if (!workspace) return; + + const payload = { ...formData }; - const onSubmit = async (formData: IUser) => { await workspaceService - .inviteWorkspace(workspace.slug, formData, user) - .then(() => { + .inviteWorkspace(workspace.slug, payload, user) + .then(async () => { setToastAlert({ type: "success", - title: "Invitations sent!", + title: "Success!", + message: "Invitations sent successfully.", }); - setStep(4); + + await nextStep(); }) .catch((err) => console.log(err)); }; - const checkEmail = watch("emails") && watch("emails").length > 0; + const appendField = () => { + append({ email: "", role: 15 }); + }; + + useEffect(() => { + if (fields.length === 0) { + append([ + { email: "", role: 15 }, + { email: "", role: 15 }, + { email: "", role: 15 }, + ]); + } + }, [fields, append]); + return (
{ if (e.code === "Enter") e.preventDefault(); }} > -
-
-

Invite co-workers to your team

-
- Email -
- +

Invite people to collaborate

+
+
+
Co-workers Email
+
Role
+
+
+ {fields.map((field, index) => ( +
+
+ ( + <> + + {errors.emails?.[index]?.email && ( + + {errors.emails?.[index]?.email?.message} + + )} + + )} + /> +
+
+ ( + {ROLE[value]}} + onChange={onChange} + width="w-full" + input + > + {Object.entries(ROLE).map(([key, value]) => ( + + {value} + + ))} + + )} + /> +
+ {fields.length > 1 && ( + + )}
-
-
- -
- - {isSubmitting ? "Inviting..." : "Continue"} - - - setStep(4)} - > - Skip - + ))}
+ +
+
+ + {isSubmitting ? "Sending..." : "Send Invite"} + + + Skip this step +
); diff --git a/apps/app/components/onboarding/join-workspaces.tsx b/apps/app/components/onboarding/join-workspaces.tsx new file mode 100644 index 000000000..47b214cb1 --- /dev/null +++ b/apps/app/components/onboarding/join-workspaces.tsx @@ -0,0 +1,155 @@ +import React, { useState } from "react"; + +import useSWR, { mutate } from "swr"; + +// services +import workspaceService from "services/workspace.service"; +import userService from "services/user.service"; +// hooks +import useUser from "hooks/use-user"; +// ui +import { PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +// helpers +import { truncateText } from "helpers/string.helper"; +// types +import { ICurrentUserResponse, IUser, IWorkspaceMemberInvitation, OnboardingSteps } from "types"; +// fetch-keys +import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; +// constants +import { ROLE } from "constants/workspace"; + +type Props = { + stepChange: (steps: Partial) => Promise; +}; + +export const JoinWorkspaces: React.FC = ({ stepChange }) => { + const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); + const [invitationsRespond, setInvitationsRespond] = useState([]); + + const { user } = useUser(); + + const { data: invitations, mutate: mutateInvitations } = 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) + ); + } + }; + + // complete onboarding + const finishOnboarding = async () => { + if (!user) return; + + mutate( + CURRENT_USER, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + is_onboarded: true, + }; + }, + false + ); + + await userService.updateUserOnBoard({ userRole: user.role }, user); + await stepChange({ workspace_join: true }); + }; + + const submitInvitations = async () => { + if (invitationsRespond.length <= 0) return; + + setIsJoiningWorkspaces(true); + + await workspaceService + .joinWorkspaces({ invitations: invitationsRespond }) + .then(async () => { + await mutateInvitations(); + await finishOnboarding(); + + setIsJoiningWorkspaces(false); + }) + .catch(() => setIsJoiningWorkspaces(false)); + }; + + return ( +
+
We see that someone has invited you to
+

Join a workspace

+
+ {invitations && + 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]}

+
+ + + +
+ ); + })} +
+
+ + Accept & Join + + + Skip for now + +
+
+ ); +}; diff --git a/apps/app/components/onboarding/onboarding-card.tsx b/apps/app/components/onboarding/onboarding-card.tsx deleted file mode 100644 index 717c90375..000000000 --- a/apps/app/components/onboarding/onboarding-card.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import Image from "next/image"; - -interface IOnboardingCard { - step: string; - title: string; - description: React.ReactNode | string; - imgURL: string; -} - -type Props = { - data: IOnboardingCard; - gradient?: boolean; -}; - -export const OnboardingCard: React.FC = ({ data, gradient = false }) => ( -
-
- {data.title} -
-

{data.title}

-

{data.description}

- {data.step} -
-); diff --git a/apps/app/components/onboarding/onboarding-logo.tsx b/apps/app/components/onboarding/onboarding-logo.tsx deleted file mode 100644 index 6efea75ea..000000000 --- a/apps/app/components/onboarding/onboarding-logo.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; - -type Props = { - className?: string; - width?: string | number; - height?: string | number; - color?: string; -}; - -export const OnboardingLogo: React.FC = ({ - width = "378", - height = "117", - color = "#858E96", - className, -}) => ( - - - - - - -); diff --git a/apps/app/components/onboarding/tour/index.ts b/apps/app/components/onboarding/tour/index.ts new file mode 100644 index 000000000..110e961a9 --- /dev/null +++ b/apps/app/components/onboarding/tour/index.ts @@ -0,0 +1,2 @@ +export * from "./root"; +export * from "./sidebar"; diff --git a/apps/app/components/onboarding/tour/root.tsx b/apps/app/components/onboarding/tour/root.tsx new file mode 100644 index 000000000..30ce2c578 --- /dev/null +++ b/apps/app/components/onboarding/tour/root.tsx @@ -0,0 +1,157 @@ +import { useState } from "react"; + +import Image from "next/image"; + +// hooks +import useUser from "hooks/use-user"; +// components +import { TourSidebar } from "components/onboarding"; +// ui +import { PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { XMarkIcon } from "@heroicons/react/24/outline"; +// images +import PlaneWhiteLogo from "public/plane-logos/white-horizontal.svg"; +import IssuesTour from "public/onboarding/issues.svg"; +import CyclesTour from "public/onboarding/cycles.svg"; +import ModulesTour from "public/onboarding/modules.svg"; +import ViewsTour from "public/onboarding/views.svg"; +import PagesTour from "public/onboarding/pages.svg"; + +type Props = { + onComplete: () => void; +}; + +export type TTourSteps = "welcome" | "issues" | "cycles" | "modules" | "views" | "pages"; + +const TOUR_STEPS: { + key: TTourSteps; + title: string; + description: string; + image: any; + prevStep?: TTourSteps; + nextStep?: TTourSteps; +}[] = [ + { + key: "issues", + title: "Plan with issues", + description: + "The issue is the building block of the Plane. Most concepts in Plane are either associated with issues and their properties.", + image: IssuesTour, + nextStep: "cycles", + }, + { + key: "cycles", + title: "Move with cycles", + description: + "Cycles help you and your team to progress faster, similar to the sprints commonly used in agile development.", + image: CyclesTour, + prevStep: "issues", + nextStep: "modules", + }, + { + key: "modules", + title: "Break into modules", + description: + "Modules break your big think into Projects or Features, to help you organize better.", + image: ModulesTour, + prevStep: "cycles", + nextStep: "views", + }, + { + key: "views", + title: "Views", + description: + "Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks.", + image: ViewsTour, + prevStep: "modules", + nextStep: "pages", + }, + { + key: "pages", + title: "Document with pages", + description: + "Modules break your big think into Projects or Features, to help you organize better.", + image: PagesTour, + prevStep: "views", + }, +]; + +export const TourRoot: React.FC = ({ onComplete }) => { + const [step, setStep] = useState("welcome"); + + const { user } = useUser(); + + const currentStep = TOUR_STEPS.find((tourStep) => tourStep.key === step); + + return ( + <> + {step === "welcome" ? ( +
+
+
+ Plane White Logo +
+
+

+ Welcome to Plane, {user?.first_name} {user?.last_name} +

+

+ We{"'"}re glad that you decided to try out Plane. You can now manage your projects + with ease. Get started by creating a project. +

+
+ setStep("issues")}>Take a Product Tour + +
+
+
+
+ ) : ( +
+ + +
+
+ {currentStep?.title} +
+
+

{currentStep?.title}

+

{currentStep?.description}

+
+
+ {currentStep?.prevStep && ( + setStep(currentStep.prevStep ?? "welcome")}> + Back + + )} + {currentStep?.nextStep && ( + setStep(currentStep.nextStep ?? "issues")}> + Next + + )} +
+ {TOUR_STEPS.findIndex((tourStep) => tourStep.key === step) === + TOUR_STEPS.length - 1 && ( + Create my first project + )} +
+
+
+
+ )} + + ); +}; diff --git a/apps/app/components/onboarding/tour/sidebar.tsx b/apps/app/components/onboarding/tour/sidebar.tsx new file mode 100644 index 000000000..ba6eba921 --- /dev/null +++ b/apps/app/components/onboarding/tour/sidebar.tsx @@ -0,0 +1,70 @@ +// icons +import { ContrastIcon, LayerDiagonalIcon, PeopleGroupIcon, ViewListIcon } from "components/icons"; +import { DocumentTextIcon } from "@heroicons/react/24/outline"; +// types +import { TTourSteps } from "./root"; + +const sidebarOptions: { + key: TTourSteps; + icon: any; +}[] = [ + { + key: "issues", + icon: LayerDiagonalIcon, + }, + { + key: "cycles", + icon: ContrastIcon, + }, + { + key: "modules", + icon: PeopleGroupIcon, + }, + { + key: "views", + icon: ViewListIcon, + }, + { + key: "pages", + icon: DocumentTextIcon, + }, +]; + +type Props = { + step: TTourSteps; + setStep: React.Dispatch>; +}; + +export const TourSidebar: React.FC = ({ step, setStep }) => ( +
+

+ Let{"'"}s get started! +
+ Get more out of Plane. +

+
+ {sidebarOptions.map((option) => ( +
setStep(option.key)} + > +
+ ))} +
+
+); diff --git a/apps/app/components/onboarding/user-details.tsx b/apps/app/components/onboarding/user-details.tsx index e981839bd..7d43ab9e0 100644 --- a/apps/app/components/onboarding/user-details.tsx +++ b/apps/app/components/onboarding/user-details.tsx @@ -1,7 +1,9 @@ import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; +import { mutate } from "swr"; +// react-hook-form +import { Controller, useForm } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; // services @@ -9,8 +11,10 @@ import userService from "services/user.service"; // ui import { CustomSelect, Input, PrimaryButton } from "components/ui"; // types -import { IUser } from "types"; -// constant +import { ICurrentUserResponse, IUser } from "types"; +// fetch-keys +import { CURRENT_USER } from "constants/fetch-keys"; +// constants import { USER_ROLES } from "constants/workspace"; const defaultValues: Partial = { @@ -21,11 +25,9 @@ const defaultValues: Partial = { type Props = { user?: IUser; - setStep: React.Dispatch>; - setUserRole: React.Dispatch>; }; -export const UserDetails: React.FC = ({ user, setStep, setUserRole }) => { +export const UserDetails: React.FC = ({ user }) => { const { setToastAlert } = useToast(); const { @@ -39,17 +41,40 @@ export const UserDetails: React.FC = ({ user, setStep, setUserRole }) => }); const onSubmit = async (formData: IUser) => { + if (!user) return; + + const payload: Partial = { + ...formData, + onboarding_step: { + ...user.onboarding_step, + profile_complete: true, + }, + }; + await userService - .updateUser(formData) + .updateUser(payload) .then(() => { + mutate( + CURRENT_USER, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + ...payload, + }; + }, + false + ); + setToastAlert({ - title: "User details updated successfully!", type: "success", + title: "Success!", + message: "Details updated successfully.", }); - setStep(2); }) .catch((err) => { - console.log(err); + mutate(CURRENT_USER); }); }; @@ -60,90 +85,88 @@ export const UserDetails: React.FC = ({ user, setStep, setUserRole }) => last_name: user.last_name, role: user.role, }); - setUserRole(user.role); } - }, [user, reset, setUserRole]); + }, [user, reset]); return ( -
-
-
-
-

User Details

-

- Enter your details as a first step to open your Plane account. -

-
+ +
+
{'"'}
+
Hey there 👋🏻
+
Let{"'"}s get you onboard!
+

Set up your Plane profile.

+
-
-
- First name - -
-
- Last name - -
-
- -
- What is your role? -
- ( - { - onChange(value); - setUserRole(value ?? null); - }} - label={value ? value.toString() : "Select your role"} - input - width="w-full" - > - {USER_ROLES.map((item) => ( - - {item.label} - - ))} - - )} - /> - {errors.role && {errors.role.message}} -
-
+
+
+ +
- -
- - {isSubmitting ? "Updating..." : "Continue"} - +
+ + +
+
+ What{"'"}s your role? +
+ ( + onChange(val)} + label={ + value ? ( + value.toString() + ) : ( + Select your role... + ) + } + input + width="w-full" + verticalPosition="top" + > + {USER_ROLES.map((item) => ( + + {item.label} + + ))} + + )} + /> + {errors.role && {errors.role.message}} +
+ + + {isSubmitting ? "Updating..." : "Continue"} + ); }; diff --git a/apps/app/components/onboarding/workspace.tsx b/apps/app/components/onboarding/workspace.tsx index 80c0b4034..ba96c50a7 100644 --- a/apps/app/components/onboarding/workspace.tsx +++ b/apps/app/components/onboarding/workspace.tsx @@ -1,249 +1,50 @@ import { useState } from "react"; -import useSWR from "swr"; - -// headless ui -import { Tab } from "@headlessui/react"; -// services -import workspaceService from "services/workspace.service"; +// ui +import { SecondaryButton } from "components/ui"; // types -import { ICurrentUserResponse, IWorkspaceMemberInvitation } from "types"; -// fetch-keys -import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; +import { ICurrentUserResponse, OnboardingSteps } from "types"; // constants import { CreateWorkspaceForm } from "components/workspace"; -// ui -import { PrimaryButton } from "components/ui"; -import { getFirstCharacters, truncateText } from "helpers/string.helper"; type Props = { - setStep: React.Dispatch>; - setWorkspace: React.Dispatch>; user: ICurrentUserResponse | undefined; + updateLastWorkspace: () => Promise; + stepChange: (steps: Partial) => Promise; }; -export const Workspace: React.FC = ({ setStep, setWorkspace, user }) => { - const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); - const [invitationsRespond, setInvitationsRespond] = useState([]); +export const Workspace: React.FC = ({ user, updateLastWorkspace, stepChange }) => { const [defaultValues, setDefaultValues] = useState({ name: "", slug: "", - company_size: null, + organization_size: "", }); - const [currentTab, setCurrentTab] = useState("create"); - const { data: invitations, mutate } = useSWR(USER_WORKSPACE_INVITATIONS, () => - workspaceService.userWorkspaceInvitations() - ); + const completeStep = async () => { + if (!user) return; - 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) - ); - } + await stepChange({ + workspace_create: true, + }); + await updateLastWorkspace(); }; - const submitInvitations = async () => { - if (invitationsRespond.length <= 0) return; - setIsJoiningWorkspaces(true); - await workspaceService - .joinWorkspaces({ invitations: invitationsRespond }) - .then(async () => { - await mutate(); - setStep(4); - setIsJoiningWorkspaces(false); - }) - .catch((err) => { - console.error(err); - setIsJoiningWorkspaces(false); - }); - }; - - const currentTabValue = (tab: string | null) => { - switch (tab) { - case "join": - return 0; - case "create": - return 1; - default: - return 1; - } - }; - - console.log("invitations:", invitations); - return ( -
- { - switch (i) { - case 0: - return setCurrentTab("join"); - case 1: - return setCurrentTab("create"); - default: - return setCurrentTab("create"); +
+

Create your workspace

+
+ stepChange({ profile_complete: false })}> + Back + } - }} - > - -
-

Workspace

-

- Create or join the workspace to get started with Plane. -

-
-
- - `rounded-3xl border px-4 py-2 outline-none ${ - selected - ? "border-custom-primary bg-custom-primary text-white font-medium" - : "border-custom-border-100 bg-custom-background-100 hover:bg-custom-background-80" - }` - } - > - Invited Workspace - - - `rounded-3xl border px-4 py-2 outline-none ${ - selected - ? "border-custom-primary bg-custom-primary text-white font-medium" - : "border-custom-border-100 bg-custom-background-100 hover:bg-custom-background-80" - }` - } - > - New Workspace - -
-
- - -
-
- {invitations && invitations.length > 0 ? ( - invitations.map((invitation) => ( -
- -
- )) - ) : ( -
-

{`You don't have any invitations yet.`}

-
- )} -
-
- - Join Workspace - -
-
-
- - { - setWorkspace(res); - setStep(3); - }} - defaultValues={defaultValues} - setDefaultValues={setDefaultValues} - user={user} - /> - -
- + /> +
); }; diff --git a/apps/app/components/project/send-project-invitation-modal.tsx b/apps/app/components/project/send-project-invitation-modal.tsx index 7435d2069..13d1c7bc5 100644 --- a/apps/app/components/project/send-project-invitation-modal.tsx +++ b/apps/app/components/project/send-project-invitation-modal.tsx @@ -158,7 +158,7 @@ const SendProjectInvitationModal: React.FC = ({ isOpen, setIsOpen, member leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
diff --git a/apps/app/components/ui/custom-search-select.tsx b/apps/app/components/ui/custom-search-select.tsx index 1a367578d..0450dc2e0 100644 --- a/apps/app/components/ui/custom-search-select.tsx +++ b/apps/app/components/ui/custom-search-select.tsx @@ -85,7 +85,7 @@ export const CustomSearchSelect = ({ disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-custom-background-80" } ${ input ? "px-3 py-2 text-sm" : "px-2.5 py-1 text-xs" - } items-center justify-between gap-1 rounded-md shadow-sm duration-300 focus:outline-none focus:ring-1 focus:ring-brand-base ${ + } items-center justify-between gap-1 rounded-md shadow-sm duration-300 focus:outline-none focus:ring-1 focus:ring-custom-border-100 ${ textAlignment === "right" ? "text-right" : textAlignment === "center" diff --git a/apps/app/components/workspace/create-workspace-form.tsx b/apps/app/components/workspace/create-workspace-form.tsx index 344955431..57aff736f 100644 --- a/apps/app/components/workspace/create-workspace-form.tsx +++ b/apps/app/components/workspace/create-workspace-form.tsx @@ -1,4 +1,4 @@ -import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import React, { Dispatch, SetStateAction, useEffect, useState } from "react"; import { mutate } from "swr"; @@ -6,27 +6,27 @@ import { mutate } from "swr"; import { Controller, useForm } from "react-hook-form"; // services import workspaceService from "services/workspace.service"; -import userService from "services/user.service"; // hooks import useToast from "hooks/use-toast"; // ui -import { CustomSelect, Input, PrimaryButton } from "components/ui"; +import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui"; // types import { ICurrentUserResponse, IWorkspace } from "types"; // fetch-keys import { USER_WORKSPACES } from "constants/fetch-keys"; // constants -import { COMPANY_SIZE } from "constants/workspace"; +import { ORGANIZATION_SIZE } from "constants/workspace"; type Props = { - onSubmit: (res: IWorkspace) => void; + onSubmit?: (res: IWorkspace) => Promise; defaultValues: { name: string; slug: string; - company_size: number | null; + organization_size: string; }; setDefaultValues: Dispatch>; user: ICurrentUserResponse | undefined; + secondaryButton?: React.ReactNode; }; const restrictedUrls = [ @@ -48,6 +48,7 @@ export const CreateWorkspaceForm: React.FC = ({ defaultValues, setDefaultValues, user, + secondaryButton, }) => { const [slugError, setSlugError] = useState(false); const [invalidSlug, setInvalidSlug] = useState(false); @@ -69,20 +70,30 @@ export const CreateWorkspaceForm: React.FC = ({ .then(async (res) => { if (res.status === true && !restrictedUrls.includes(formData.slug)) { setSlugError(false); + await workspaceService .createWorkspace(formData, user) - .then((res) => { + .then(async (res) => { setToastAlert({ type: "success", title: "Success!", message: "Workspace created successfully.", }); - mutate(USER_WORKSPACES, (prevData) => [res, ...(prevData ?? [])]); - updateLastWorkspaceIdUnderUSer(res); + + mutate( + USER_WORKSPACES, + (prevData) => [res, ...(prevData ?? [])], + false + ); + if (onSubmit) await onSubmit(res); }) - .catch((err) => { - console.error(err); - }); + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Workspace could not be created. Please try again.", + }) + ); } else setSlugError(true); }) .catch(() => { @@ -94,18 +105,6 @@ export const CreateWorkspaceForm: React.FC = ({ }); }; - // update last_workspace_id - const updateLastWorkspaceIdUnderUSer = (workspace: any) => { - userService - .updateUser({ last_workspace_id: workspace.id }) - .then((res) => { - onSubmit(workspace); - }) - .catch((err) => { - console.log(err); - }); - }; - useEffect( () => () => { // when the component unmounts set the default values to whatever user typed in @@ -115,65 +114,63 @@ export const CreateWorkspaceForm: React.FC = ({ ); return ( -
-
-
-
- Workspace name + +
+
+ + + setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-")) + } + validations={{ + required: "Workspace name is required", + validate: (value) => + /^[\w\s-]*$/.test(value) || + `Name can only contain (" "), ( - ), ( _ ) & alphanumeric characters.`, + }} + placeholder="Enter workspace name..." + error={errors.name} + /> +
+
+ +
+ + {window && window.location.host}/ + - setValue("slug", e.target.value.toLocaleLowerCase().trim().replace(/ /g, "-")) - } + name="slug" + register={register} + className="block w-full rounded-md bg-transparent py-2 !px-0 text-sm" validations={{ - required: "Workspace name is required", - validate: (value) => - /^[\w\s-]*$/.test(value) || - `Name can only contain (" "), ( - ), ( _ ) & Alphanumeric characters.`, + required: "Workspace URL is required", }} - placeholder="e.g. My Workspace" - className="placeholder:text-custom-text-200" - error={errors.name} + onChange={(e) => + /^[a-zA-Z0-9_-]+$/.test(e.target.value) + ? setInvalidSlug(false) + : setInvalidSlug(true) + } />
-
- Workspace URL -
- - {typeof window !== "undefined" && window.location.origin}/ - - - /^[a-zA-Z0-9_-]+$/.test(e.target.value) - ? setInvalidSlug(false) - : setInvalidSlug(true) - } - /> -
- {slugError && ( - Workspace URL is already taken! - )} - {invalidSlug && ( - {`URL can only contain ( - ), ( _ ) & Alphanumeric characters.`} - )} -
+ {slugError && ( + Workspace URL is already taken! + )} + {invalidSlug && ( + {`URL can only contain ( - ), ( _ ) & alphanumeric characters.`} + )}
- -
- How large is your company? +
+ What size is your organization?
( @@ -181,37 +178,31 @@ export const CreateWorkspaceForm: React.FC = ({ value={value} onChange={onChange} label={ - value ? ( - value.toString() - ) : ( - Select company size + ORGANIZATION_SIZE.find((c) => c === value) ?? ( + Select organization size ) } input width="w-full" > - {COMPANY_SIZE?.map((item) => ( - - {item.label} + {ORGANIZATION_SIZE.map((item) => ( + + {item} ))} )} /> - {errors.company_size && ( - {errors.company_size.message} + {errors.organization_size && ( + {errors.organization_size.message} )}
-
- +
+ {secondaryButton} + {isSubmitting ? "Creating..." : "Create Workspace"}
diff --git a/apps/app/constants/workspace.ts b/apps/app/constants/workspace.ts index 8ab10c09d..ab8c8b069 100644 --- a/apps/app/constants/workspace.ts +++ b/apps/app/constants/workspace.ts @@ -15,58 +15,21 @@ export const ROLE = { 20: "Admin", }; -export const COMPANY_SIZE = [ - { value: 5, label: "5" }, - { value: 10, label: "10" }, - { value: 25, label: "25" }, - { value: 50, label: "50" }, -]; +export const ORGANIZATION_SIZE = ["Just myself", "2-10", "11-50", "51-200", "201-500", "500+"]; export const USER_ROLES = [ - { value: "Founder or leadership team", label: "Founder or leadership team" }, - { value: "Product manager", label: "Product manager" }, - { value: "Designer", label: "Designer" }, - { value: "Software developer", label: "Software developer" }, - { value: "Freelancer", label: "Freelancer" }, + { value: "Product / Project Manager", label: "Product / Project Manager" }, + { value: "Development / Engineering", label: "Development / Engineering" }, + { value: "Founder / Executive", label: "Founder / Executive" }, + { value: "Freelancer / Consultant", label: "Freelancer / Consultant" }, + { value: "Marketing / Growth", label: "Marketing / Growth" }, + { value: "Sales / Business Development", label: "Sales / Business Development" }, + { value: "Support / Operations", label: "Support / Operations" }, + { value: "Student / Professor", label: "Student / Professor" }, + { value: "Human Resources", label: "Human Resources" }, { value: "Other", label: "Other" }, ]; -export const ONBOARDING_CARDS = { - welcome: { - imgURL: Welcome, - step: "1/5", - title: "Welcome to Plane", - description: "Plane helps you plan your issues, cycles, and product modules to ship faster.", - }, - issue: { - imgURL: Issue, - step: "2/5", - title: "Plan with Issues", - description: - "Issues are the building blocks of Plane. Most concepts in Plane are associated with issues or their properties.", - }, - cycle: { - imgURL: Cycle, - step: "3/5", - title: "Move with Cycles", - description: - "Cycles help you and your team progress faster, similar to sprints commonly used in agile development.", - }, - module: { - imgURL: Module, - step: "4/5", - title: "Break into Modules ", - description: - "Modules break your big thoughts into Projects or Features, to help you organize better.", - }, - commandMenu: { - imgURL: CommandMenu, - step: "5 /5", - title: "Command Menu", - description: "With Command Menu, you can create, update, and navigate across the platform.", - }, -}; - export const IMPORTERS_EXPORTERS_LIST = [ { provider: "github", diff --git a/apps/app/hooks/use-workspaces.tsx b/apps/app/hooks/use-workspaces.tsx index 7e3c59690..d924d0f69 100644 --- a/apps/app/hooks/use-workspaces.tsx +++ b/apps/app/hooks/use-workspaces.tsx @@ -1,10 +1,10 @@ -import useSWR from "swr"; import { useRouter } from "next/router"; -// types -import { IWorkspace } from "types"; + +import useSWR from "swr"; + // services import workspaceService from "services/workspace.service"; -// constants +// fetch-keys import { USER_WORKSPACES } from "constants/fetch-keys"; const useWorkspaces = () => { @@ -12,11 +12,7 @@ const useWorkspaces = () => { const router = useRouter(); const { workspaceSlug } = router.query; // API to fetch user information - const { - data = [], - error, - mutate, - } = useSWR(USER_WORKSPACES, () => workspaceService.userWorkspaces()); + const { data, error, mutate } = useSWR(USER_WORKSPACES, () => workspaceService.userWorkspaces()); // active workspace const activeWorkspace = data?.find((w) => w.slug === workspaceSlug); diff --git a/apps/app/layouts/default-layout/index.tsx b/apps/app/layouts/default-layout/index.tsx index 5113595fc..483803a0a 100644 --- a/apps/app/layouts/default-layout/index.tsx +++ b/apps/app/layouts/default-layout/index.tsx @@ -9,7 +9,7 @@ type Props = { }; const DefaultLayout: React.FC = ({ children }) => ( -
+
<>{children}
); diff --git a/apps/app/pages/[workspaceSlug]/index.tsx b/apps/app/pages/[workspaceSlug]/index.tsx index 5a478a6ac..9f7b8589c 100644 --- a/apps/app/pages/[workspaceSlug]/index.tsx +++ b/apps/app/pages/[workspaceSlug]/index.tsx @@ -19,6 +19,7 @@ import { IssuesPieChart, IssuesStats, } from "components/workspace"; +import { TourRoot } from "components/onboarding"; // ui import { PrimaryButton, ProductUpdatesModal } from "components/ui"; // images @@ -26,9 +27,10 @@ import emptyDashboard from "public/empty-state/dashboard.svg"; // helpers import { render12HourFormatTime, renderShortDate } from "helpers/date-time.helper"; // types +import { ICurrentUserResponse } from "types"; import type { NextPage } from "next"; // fetch-keys -import { USER_WORKSPACE_DASHBOARD } from "constants/fetch-keys"; +import { CURRENT_USER, USER_WORKSPACE_DASHBOARD } from "constants/fetch-keys"; // constants import { DAYS } from "constants/project"; @@ -65,6 +67,28 @@ const WorkspacePage: NextPage = () => { setIsOpen={setIsProductUpdatesModalOpen} /> )} + {user && !user.is_tour_completed && ( +
+ { + mutate( + CURRENT_USER, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + is_tour_completed: true, + }; + }, + false + ); + + userService.updateUserTourCompleted(user).catch(() => mutate(CURRENT_USER)); + }} + /> +
+ )} {projects ? ( projects.length > 0 ? (
diff --git a/apps/app/pages/[workspaceSlug]/settings/index.tsx b/apps/app/pages/[workspaceSlug]/settings/index.tsx index 969d2c6fd..476f08861 100644 --- a/apps/app/pages/[workspaceSlug]/settings/index.tsx +++ b/apps/app/pages/[workspaceSlug]/settings/index.tsx @@ -31,12 +31,12 @@ import type { NextPage } from "next"; // fetch-keys import { WORKSPACE_DETAILS, USER_WORKSPACES } from "constants/fetch-keys"; // constants -import { COMPANY_SIZE } from "constants/workspace"; +import { ORGANIZATION_SIZE } from "constants/workspace"; const defaultValues: Partial = { name: "", url: "", - company_size: null, + organization_size: "2-10", logo: null, }; @@ -80,7 +80,7 @@ const WorkspaceSettings: NextPage = () => { const payload: Partial = { logo: formData.logo, name: formData.name, - company_size: formData.company_size, + organization_size: formData.organization_size, }; await workspaceService @@ -281,18 +281,18 @@ const WorkspaceSettings: NextPage = () => {
( c === value) ?? "Select company size"} input > - {COMPANY_SIZE?.map((item) => ( - - {item.label} + {ORGANIZATION_SIZE?.map((item) => ( + + {item} ))} diff --git a/apps/app/pages/create-workspace.tsx b/apps/app/pages/create-workspace.tsx index de7888da9..b6aa41728 100644 --- a/apps/app/pages/create-workspace.tsx +++ b/apps/app/pages/create-workspace.tsx @@ -1,60 +1,100 @@ -import React from "react"; +import React, { useState } from "react"; import { useRouter } from "next/router"; import Image from "next/image"; + +import { mutate } from "swr"; + +// next-themes +import { useTheme } from "next-themes"; +// services +import userService from "services/user.service"; // hooks import useUser from "hooks/use-user"; -// components -import { OnboardingLogo } from "components/onboarding"; // layouts import DefaultLayout from "layouts/default-layout"; import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper"; -// images -import Logo from "public/onboarding/logo.svg"; -// types -import type { NextPage } from "next"; -// constants +// components import { CreateWorkspaceForm } from "components/workspace"; +// images +import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg"; +import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; +// types +import { ICurrentUserResponse, IWorkspace } from "types"; +import type { NextPage } from "next"; +// fetch-keys +import { CURRENT_USER } from "constants/fetch-keys"; const CreateWorkspace: NextPage = () => { - const router = useRouter(); - const defaultValues = { + const [defaultValues, setDefaultValues] = useState({ name: "", slug: "", - company_size: null, - }; + organization_size: "", + }); + + const router = useRouter(); + + const { theme } = useTheme(); const { user } = useUser(); + + const onSubmit = async (workspace: IWorkspace) => { + mutate( + CURRENT_USER, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + last_workspace_id: workspace.id, + workspace: { + ...prevData.workspace, + fallback_workspace_id: workspace.id, + fallback_workspace_slug: workspace.slug, + last_workspace_id: workspace.id, + last_workspace_slug: workspace.slug, + }, + }; + }, + false + ); + + await userService + .updateUser({ last_workspace_id: workspace.id }) + .then(() => router.push(`/${workspace.slug}`)); + }; + return ( -
-
-
- -
- -
-
-
-

Create Workspace

-

- Create or join the workspace to get started with Plane. -

-
+
+
+
+
+
+ {theme === "light" ? ( + Plane black logo + ) : ( + Plane white logo + )}
- {}} - onSubmit={(res) => router.push(`/${res.slug}`)} - user={user} - /> +
+
+ {user?.email}
- -
- Logged in: - {user?.email} +
+
+

Create your workspace

+
+ +
+
diff --git a/apps/app/pages/index.tsx b/apps/app/pages/index.tsx index b6b8aad59..6f36df530 100644 --- a/apps/app/pages/index.tsx +++ b/apps/app/pages/index.tsx @@ -20,7 +20,7 @@ import { // ui import { Spinner } from "components/ui"; // icons -import Logo from "public/logo.png"; +import Logo from "public/plane-logos/blue-without-text.png"; // types type EmailPasswordFormValues = { email: string; diff --git a/apps/app/pages/invitations.tsx b/apps/app/pages/invitations.tsx index 6a44bcc8d..5639fc3f5 100644 --- a/apps/app/pages/invitations.tsx +++ b/apps/app/pages/invitations.tsx @@ -1,10 +1,12 @@ import React, { useState } from "react"; import Link from "next/link"; -import { useRouter } from "next/router"; +import Image from "next/image"; -import useSWR from "swr"; +import useSWR, { mutate } from "swr"; +// next-themes +import { useTheme } from "next-themes"; // services import workspaceService from "services/workspace.service"; // hooks @@ -13,36 +15,37 @@ import useToast from "hooks/use-toast"; // layouts import DefaultLayout from "layouts/default-layout"; import { UserAuthorizationLayout } from "layouts/auth-layout/user-authorization-wrapper"; -// components -import SingleInvitation from "components/workspace/single-invitation"; -import { OnboardingLogo } from "components/onboarding"; // ui -import { Spinner, EmptySpace, EmptySpaceItem, SecondaryButton, PrimaryButton } from "components/ui"; +import { SecondaryButton, PrimaryButton } from "components/ui"; // icons -import { CubeIcon, PlusIcon } from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +// images +import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg"; +import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; +// helpers +import { truncateText } from "helpers/string.helper"; // types import type { NextPage } from "next"; import type { IWorkspaceMemberInvitation } from "types"; // fetch-keys import { USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; +// constants +import { ROLE } from "constants/workspace"; const OnBoard: NextPage = () => { const [invitationsRespond, setInvitationsRespond] = useState([]); + const [isJoiningWorkspaces, setIsJoiningWorkspaces] = useState(false); + + const { theme } = useTheme(); const { user } = useUser(); - const router = useRouter(); - const { setToastAlert } = useToast(); const { data: invitations, mutate: mutateInvitations } = useSWR(USER_WORKSPACE_INVITATIONS, () => workspaceService.userWorkspaceInvitations() ); - const { data: workspaces, mutate: mutateWorkspaces } = useSWR("USER_WORKSPACES", () => - workspaceService.userWorkspaces() - ); - const handleInvitation = ( workspace_invitation: IWorkspaceMemberInvitation, action: "accepted" | "withdraw" @@ -57,118 +60,120 @@ const OnBoard: NextPage = () => { }; const submitInvitations = () => { - // userService.updateUserOnBoard(); - if (invitationsRespond.length === 0) { setToastAlert({ type: "error", title: "Error!", - message: "Please select atleast one invitation.", + message: "Please select at least one invitation.", }); return; } + setIsJoiningWorkspaces(true); + workspaceService .joinWorkspaces({ invitations: invitationsRespond }) .then(() => { mutateInvitations(); - mutateWorkspaces(); + mutate("USER_WORKSPACES"); + + setIsJoiningWorkspaces(false); }) - .catch((err) => { - console.log(err); - }); + .catch((err) => setIsJoiningWorkspaces(false)); }; return ( -
-
-
- -
- -
- {invitations && workspaces ? ( - invitations.length > 0 ? ( -
-
-

- Workspace Invitations -

-

- Create or join the workspace to get started with Plane. -

-
- -
    - {invitations.map((invitation) => ( - - ))} -
- -
- - - Go Home - - - - Accept and Continue - -
-
- ) : workspaces && workspaces.length > 0 ? ( -
-

Your workspaces

- {workspaces.map((workspace) => ( - - -
-
- - {workspace.name} -
-
- {workspace.owner.first_name} -
-
-
- - ))} -
+
+
+
+
+
+ {theme === "light" ? ( + Plane black logo ) : ( - invitations.length === 0 && - workspaces.length === 0 && ( - - { - router.push("/create-workspace"); - }} - /> - - ) - ) - ) : ( -
- -
- )} + Plane white logo + )} +
+
+
+ {user?.email}
-
- Logged in: - {user?.email} +
+
+
We see that someone has invited you to
+

Join a workspace

+
+ {invitations && + 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]}

+
+ + + +
+ ); + })} +
+
+ + Accept & Join + + + + + Go Home + + + +
+
diff --git a/apps/app/pages/onboarding.tsx b/apps/app/pages/onboarding.tsx index 2fd3c4575..e9373fc10 100644 --- a/apps/app/pages/onboarding.tsx +++ b/apps/app/pages/onboarding.tsx @@ -1,134 +1,198 @@ import { useEffect, useState } from "react"; -// next imports + import Router from "next/router"; +import Image from "next/image"; + +import useSWR, { mutate } from "swr"; + +// next-themes +import { useTheme } from "next-themes"; // services import userService from "services/user.service"; import workspaceService from "services/workspace.service"; // hooks import useUserAuth from "hooks/use-user-auth"; +import useWorkspaces from "hooks/use-workspaces"; // layouts import DefaultLayout from "layouts/default-layout"; // components -import { - InviteMembers, - OnboardingCard, - OnboardingLogo, - UserDetails, - Workspace, -} from "components/onboarding"; +import { InviteMembers, JoinWorkspaces, UserDetails, Workspace } from "components/onboarding"; // ui -import { PrimaryButton, Spinner } from "components/ui"; -// constant -import { ONBOARDING_CARDS } from "constants/workspace"; +import { Spinner } from "components/ui"; +// images +import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; +import BlackHorizontalLogo from "public/plane-logos/black-horizontal-with-blue-logo.svg"; +import WhiteHorizontalLogo from "public/plane-logos/white-horizontal-with-blue-logo.svg"; // types +import { ICurrentUserResponse, IUser, OnboardingSteps } from "types"; import type { NextPage } from "next"; +// fetch-keys +import { CURRENT_USER, USER_WORKSPACE_INVITATIONS } from "constants/fetch-keys"; const Onboarding: NextPage = () => { - const [step, setStep] = useState(null); - const [userRole, setUserRole] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [step, setStep] = useState(null); - const [workspace, setWorkspace] = useState(); + const { theme } = useTheme(); - const { user, isLoading: userLoading, mutateUser } = useUserAuth("onboarding"); + const { user, isLoading: userLoading } = useUserAuth("onboarding"); + + const { workspaces } = useWorkspaces(); + const userWorkspaces = workspaces?.filter((w) => w.created_by === user?.id); + + const { data: invitations } = useSWR(USER_WORKSPACE_INVITATIONS, () => + workspaceService.userWorkspaceInvitations() + ); + + const updateLastWorkspace = async () => { + if (!userWorkspaces) return; + + mutate( + CURRENT_USER, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + last_workspace_id: userWorkspaces[0]?.id, + workspace: { + ...prevData.workspace, + fallback_workspace_id: userWorkspaces[0]?.id, + fallback_workspace_slug: userWorkspaces[0]?.slug, + last_workspace_id: userWorkspaces[0]?.id, + last_workspace_slug: userWorkspaces[0]?.slug, + }, + }; + }, + false + ); + + await userService.updateUser({ last_workspace_id: userWorkspaces?.[0]?.id }); + }; + + const stepChange = async (steps: Partial) => { + if (!user) return; + + const payload: Partial = { + onboarding_step: { + ...user.onboarding_step, + ...steps, + }, + }; + + mutate( + CURRENT_USER, + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + ...payload, + }; + }, + false + ); + + await userService.updateUser(payload); + }; useEffect(() => { - if (user && step === null) { - let currentStep: number = 1; - if (user?.role) currentStep = 2; - if (user?.last_workspace_id) currentStep = 4; - setStep(() => currentStep); - } - }, [step, user]); + const handleStepChange = async () => { + if (!user || !userWorkspaces || !invitations) return; + + const onboardingStep = user.onboarding_step; + + if (!onboardingStep.profile_complete && step !== 1) setStep(1); + + if (onboardingStep.profile_complete && !onboardingStep.workspace_create && step !== 2) + setStep(2); + + if ( + onboardingStep.profile_complete && + onboardingStep.workspace_create && + !onboardingStep.workspace_invite && + step !== 3 + ) + setStep(3); + + if ( + onboardingStep.profile_complete && + onboardingStep.workspace_create && + onboardingStep.workspace_invite && + !onboardingStep.workspace_join && + step !== 4 + ) { + if (invitations.length > 0) setStep(4); + else await Router.push("/"); + } + }; + + handleStepChange(); + }, [user, invitations, userWorkspaces, step]); + + if (userLoading || step === null) + return ( +
+ +
+ ); return ( - {userLoading || isLoading || step === null ? ( -
- -
- ) : ( -
- {step <= 3 ? ( -
-
- +
+
+
+ {step === 1 ? ( +
+
+ Plane logo
- {step === 1 ? ( - - ) : step === 2 ? ( - - ) : ( - step === 3 && - )}
) : ( -
-
- {step === 4 ? ( - - ) : step === 5 ? ( - - ) : step === 6 ? ( - - ) : step === 7 ? ( - +
+
+ {theme === "light" ? ( + Plane black logo ) : ( - step === 8 && + Plane white logo )} -
- { - if (step === 8) { - setIsLoading(true); - userService - .updateUserOnBoard({ userRole }, user) - .then(async () => { - mutateUser(); - const userWorkspaces = await workspaceService.userWorkspaces(); - - const lastActiveWorkspace = - userWorkspaces.find( - (workspace) => workspace.id === user?.last_workspace_id - ) ?? userWorkspaces[0]; - - if (lastActiveWorkspace) { - mutateUser(); - Router.push(`/${lastActiveWorkspace.slug}`); - return; - } else { - const invitations = await workspaceService.userWorkspaceInvitations(); - if (invitations.length > 0) { - Router.push(`/invitations`); - return; - } else { - Router.push(`/create-workspace`); - return; - } - } - }) - .catch((err) => { - setIsLoading(false); - console.log(err); - }); - } else setStep((prevData) => (prevData != null ? prevData + 1 : prevData)); - }} - > - {step === 4 || step === 8 ? "Get Started" : "Next"} - -
)} -
- Logged in: - {user?.email} +
+ {user?.email}
- )} +
+ {step === 1 ? ( + + ) : step === 2 ? ( + + ) : step === 3 ? ( + + ) : ( + step === 4 && + )} +
+ {step !== 4 && ( +
+
+

{step} of 3 steps

+
+
+
+
+
+ )} +
); }; diff --git a/apps/app/pages/reset-password.tsx b/apps/app/pages/reset-password.tsx index 748050021..34faf3021 100644 --- a/apps/app/pages/reset-password.tsx +++ b/apps/app/pages/reset-password.tsx @@ -14,7 +14,7 @@ import DefaultLayout from "layouts/default-layout"; // ui import { Input, SecondaryButton } from "components/ui"; // icons -import Logo from "public/logo.png"; +import Logo from "public/plane-logos/blue-without-text.png"; // types import type { NextPage } from "next"; diff --git a/apps/app/pages/sign-up.tsx b/apps/app/pages/sign-up.tsx index edd17b5b3..d0ae3ddb1 100644 --- a/apps/app/pages/sign-up.tsx +++ b/apps/app/pages/sign-up.tsx @@ -13,7 +13,7 @@ import DefaultLayout from "layouts/default-layout"; // components import { EmailPasswordForm } from "components/account"; // images -import Logo from "public/logo.png"; +import Logo from "public/plane-logos/blue-without-text.png"; // types import type { NextPage } from "next"; type EmailPasswordFormValues = { diff --git a/apps/app/public/onboarding/command-menu.svg b/apps/app/public/onboarding/command-menu.svg deleted file mode 100644 index 26a5d5fe6..000000000 --- a/apps/app/public/onboarding/command-menu.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/apps/app/public/onboarding/cycle.svg b/apps/app/public/onboarding/cycle.svg deleted file mode 100644 index 2841d367a..000000000 --- a/apps/app/public/onboarding/cycle.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/app/public/onboarding/cycles.svg b/apps/app/public/onboarding/cycles.svg new file mode 100644 index 000000000..594192b21 --- /dev/null +++ b/apps/app/public/onboarding/cycles.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/onboarding/issue.svg b/apps/app/public/onboarding/issue.svg deleted file mode 100644 index 1501e07d5..000000000 --- a/apps/app/public/onboarding/issue.svg +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/app/public/onboarding/issues.svg b/apps/app/public/onboarding/issues.svg new file mode 100644 index 000000000..04b0018cc --- /dev/null +++ b/apps/app/public/onboarding/issues.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/onboarding/logo.svg b/apps/app/public/onboarding/logo.svg deleted file mode 100644 index 4c39a394c..000000000 --- a/apps/app/public/onboarding/logo.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/apps/app/public/onboarding/module.svg b/apps/app/public/onboarding/module.svg deleted file mode 100644 index 2058bb4f3..000000000 --- a/apps/app/public/onboarding/module.svg +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/app/public/onboarding/modules.svg b/apps/app/public/onboarding/modules.svg new file mode 100644 index 000000000..505094f1c --- /dev/null +++ b/apps/app/public/onboarding/modules.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/onboarding/pages.svg b/apps/app/public/onboarding/pages.svg new file mode 100644 index 000000000..a7141522e --- /dev/null +++ b/apps/app/public/onboarding/pages.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/onboarding/views.svg b/apps/app/public/onboarding/views.svg new file mode 100644 index 000000000..0736ebc12 --- /dev/null +++ b/apps/app/public/onboarding/views.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/onboarding/welcome.svg b/apps/app/public/onboarding/welcome.svg deleted file mode 100644 index d53ed2d77..000000000 --- a/apps/app/public/onboarding/welcome.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/app/public/plane-logos/black-horizontal-with-blue-logo.svg b/apps/app/public/plane-logos/black-horizontal-with-blue-logo.svg new file mode 100644 index 000000000..ae79919fc --- /dev/null +++ b/apps/app/public/plane-logos/black-horizontal-with-blue-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/logo.png b/apps/app/public/plane-logos/blue-without-text.png similarity index 100% rename from apps/app/public/logo.png rename to apps/app/public/plane-logos/blue-without-text.png diff --git a/apps/app/public/plane-logos/white-horizontal-with-blue-logo.svg b/apps/app/public/plane-logos/white-horizontal-with-blue-logo.svg new file mode 100644 index 000000000..d8cc6f4ef --- /dev/null +++ b/apps/app/public/plane-logos/white-horizontal-with-blue-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/plane-logos/white-horizontal.svg b/apps/app/public/plane-logos/white-horizontal.svg new file mode 100644 index 000000000..13e2dbb9f --- /dev/null +++ b/apps/app/public/plane-logos/white-horizontal.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/apps/app/public/sign-up-sideimg.svg b/apps/app/public/sign-up-sideimg.svg deleted file mode 100644 index da9d67b52..000000000 --- a/apps/app/public/sign-up-sideimg.svg +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/app/services/track-event.service.ts b/apps/app/services/track-event.service.ts index 00eb4fc97..bbc6a1a58 100644 --- a/apps/app/services/track-event.service.ts +++ b/apps/app/services/track-event.service.ts @@ -193,6 +193,23 @@ class TrackEventServices extends APIService { }); } + async trackUserTourCompleteEvent( + data: any, + user: ICurrentUserResponse | undefined + ): Promise { + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName: "USER_TOUR_COMPLETE", + extra: { + ...data, + }, + user: user, + }, + }); + } + async trackIssueEvent( data: IIssue | any, eventName: IssueEventType, diff --git a/apps/app/services/user.service.ts b/apps/app/services/user.service.ts index 3fe8852a0..955867fcf 100644 --- a/apps/app/services/user.service.ts +++ b/apps/app/services/user.service.ts @@ -69,6 +69,25 @@ class UserService extends APIService { }); } + async updateUserTourCompleted(user: ICurrentUserResponse): Promise { + return this.patch("/api/users/me/tour-completed/", { + is_tour_completed: true, + }) + .then((response) => { + if (trackEvent) + trackEventServices.trackUserTourCompleteEvent( + { + user_role: user.role ?? "None", + }, + user + ); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + async getUserActivity(): Promise { return this.get("/api/users/activities/") .then((response) => response?.data) diff --git a/apps/app/services/workspace.service.ts b/apps/app/services/workspace.service.ts index 21ec7c1df..e44b6f4eb 100644 --- a/apps/app/services/workspace.service.ts +++ b/apps/app/services/workspace.service.ts @@ -13,6 +13,7 @@ import { IWorkspaceSearchResults, IProductUpdateResponse, ICurrentUserResponse, + IWorkspaceBulkInviteFormData, } from "types"; const trackEvent = @@ -87,7 +88,7 @@ class WorkspaceService extends APIService { async inviteWorkspace( workspaceSlug: string, - data: any, + data: IWorkspaceBulkInviteFormData, user: ICurrentUserResponse | undefined ): Promise { return this.post(`/api/workspaces/${workspaceSlug}/invite/`, data) diff --git a/apps/app/styles/globals.css b/apps/app/styles/globals.css index 9b3f1f990..964d77ca9 100644 --- a/apps/app/styles/globals.css +++ b/apps/app/styles/globals.css @@ -45,26 +45,6 @@ --color-text-100: 23, 23, 23; /* primary text */ --color-text-200: 82, 82, 82; /* secondary text */ --color-text-300: 115, 115, 115; /* tertiary text */ - - --color-sidebar-background-100: 255, 255, 255; /* primary sidebar bg */ - --color-sidebar-background-90: 250, 250, 250; /* secondary sidebar bg */ - --color-sidebar-background-80: 245, 245, 245; /* tertiary sidebar bg */ - - --color-sidebar-text-100: 23, 23, 23; /* primary sidebar text */ - --color-sidebar-text-200: 82, 82, 82; /* secondary sidebar text */ - --color-sidebar-text-300: 115, 115, 115; /* tertiary sidebar text */ - } - - [data-theme="light"] { - color-scheme: light !important; - - --color-background-100: 255, 255, 255; /* primary bg */ - --color-background-90: 250, 250, 250; /* secondary bg */ - --color-background-80: 245, 245, 245; /* tertiary bg */ - - --color-text-100: 23, 23, 23; /* primary text */ - --color-text-200: 58, 58, 58; /* secondary text */ - --color-text-300: 82, 82, 82; /* tertiary text */ --color-text-400: 163, 163, 163; /* placeholder text */ --color-border-100: 245, 245, 245; /* subtle border= 1 */ diff --git a/apps/app/types/users.d.ts b/apps/app/types/users.d.ts index d5810b4d3..472e79636 100644 --- a/apps/app/types/users.d.ts +++ b/apps/app/types/users.d.ts @@ -1,29 +1,30 @@ import { IIssue, IIssueLite, IWorkspace, NestedKeyOf, Properties } from "./"; export interface IUser { - id: readonly string; - last_login: readonly Date; avatar: string; - username: string; - mobile_number: string; + created_at: readonly Date; + created_location: readonly string; + date_joined: readonly Date; email: string; first_name: string; - last_name: string; - date_joined: readonly Date; - created_at: readonly Date; - updated_at: readonly Date; - last_location: readonly string; - created_location: readonly string; + id: readonly string; is_email_verified: boolean; is_onboarded: boolean; - token: string; - role: string; - theme: ICustomTheme; - - my_issues_prop?: { + is_tour_completed: boolean; + last_location: readonly string; + last_login: readonly Date; + last_name: string; + mobile_number: string; + my_issues_prop: { properties: Properties; groupBy: NestedKeyOf | null; - }; + } | null; + onboarding_step: OnboardingSteps; + role: string; + token: string; + theme: ICustomTheme; + updated_at: readonly Date; + username: string; [...rest: string]: any; } @@ -40,9 +41,15 @@ export interface ICustomTheme { export interface ICurrentUserResponse extends IUser { assigned_issues: number; - // user: IUser; + last_workspace_id: string | null; workspace_invites: number; - is_onboarded: boolean; + workspace: { + fallback_workspace_id: string | null; + fallback_workspace_slug: string | null; + invites: number; + last_workspace_id: string | null; + last_workspace_slug: string | null; + }; } export interface IUserLite { @@ -119,3 +126,10 @@ export type UserAuth = { isViewer: boolean; isGuest: boolean; }; + +export type OnboardingSteps = { + profile_complete: boolean; + workspace_create: boolean; + workspace_invite: boolean; + workspace_join: boolean; +}; diff --git a/apps/app/types/workspace.d.ts b/apps/app/types/workspace.d.ts index 2e060abdd..5dbcc1ff2 100644 --- a/apps/app/types/workspace.d.ts +++ b/apps/app/types/workspace.d.ts @@ -13,7 +13,7 @@ export interface IWorkspace { readonly slug: string; readonly created_by: string; readonly updated_by: string; - company_size: number | null; + organization_size: string; total_issues: number | null; } @@ -35,6 +35,10 @@ export interface IWorkspaceMemberInvitation { workspace: IWorkspace; } +export interface IWorkspaceBulkInviteFormData { + emails: { email: string; role: 5 | 10 | 15 | 20 }[]; +} + export type Properties = { assignee: boolean; due_date: boolean;