Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Interactive onboarding #179

Merged
merged 5 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions components/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ const api = new ClientAPI("gearboxiscool");
type ContainerProps = {
children: ReactNode;
requireAuthentication: boolean;
hideMenu: boolean;
/**
* Hides the button to open the sidebar.
*/
hideMenu?: boolean;
notForMobile?: boolean | undefined;
};

Expand Down Expand Up @@ -134,7 +137,7 @@ export default function Container(props: ContainerProps) {
<div className="drawer-content">
<div className=" sm:w-full h-16 bg-base-200 flex flex-row items-center justify-between sticky top-0 z-10">
<div className="flex flex-row items-center sm:justify-center">
{authenticated && !props.hideMenu ? (
{authenticated && !(props.hideMenu ?? false) ? (
<label htmlFor="menu" className="btn btn-ghost drawer-button">
<BiMenu className="text-3xl" />
</label>
Expand Down
2 changes: 1 addition & 1 deletion components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export default function Footer() {
></MdAlternateEmail>
Contact
</a>
<a className="link link-hover" href={"https://discord.gg/ha7AnqxFDD"}>
<a className="link link-hover" href="https://discord.gg/ha7AnqxFDD">
<FaBug className="inline mr-1" size={16}></FaBug>
Bug Report/Feature Request
</a>
Expand Down
23 changes: 16 additions & 7 deletions lib/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,19 @@ import { xpToLevel } from "./Xp";

export namespace API {
export const GearboxHeader = "gearbox-auth";

type RouteContents<TData = any> = {
slackClient: WebClient;
db: MongoDBInterface;
tba: TheBlueAlliance.Interface;
data: TData;
};


type Route = (
req: NextApiRequest,
res: NextApiResponse,
contents: {
slackClient: WebClient;
db: MongoDBInterface;
tba: TheBlueAlliance.Interface;
data: any;
}
contents: RouteContents
) => Promise<void>;
type RouteCollection = { [routeName: string]: Route };

Expand Down Expand Up @@ -225,7 +229,7 @@ export namespace API {

// modification

teamRequest: async (req, res, { db, data }) => {
requestToJoinTeam: async (req, res, { db, data }) => {
// {
// teamId
// userId
Expand Down Expand Up @@ -885,6 +889,11 @@ export namespace API {
return res.status(200).send({ result: "success" });
},

setOnboardingCompleted: async (req, res, { db, data }: RouteContents<{userId: string}>) => {
await db.updateObjectById<User>(Collections.Users, new ObjectId(data.userId), { onboardingComplete: true });
return res.status(200).send({ result: "success" });
},

submitSubjectiveReport: async (req, res, { db, data }) => {
const rawReport = data.report as SubjectiveReport;

Expand Down
3 changes: 1 addition & 2 deletions lib/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,7 @@ export const AuthenticationOptions: AuthOptions = {
},

async redirect({ url, baseUrl }) {
baseUrl = "https://4026.org/profile";
return baseUrl;
return baseUrl + "/onboarding";
},
},
debug: false,
Expand Down
1 change: 1 addition & 0 deletions lib/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class User implements NextAuthUser {
slackId: string = "";
xp: number = 10;
level: number = 1;
onboardingComplete: boolean = false;

constructor(
name: string | undefined,
Expand Down
12 changes: 8 additions & 4 deletions lib/client/ClientAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ export default class ClientAPI {
return await this.request("/matchAutofill", { tbaId: tbaId });
}

async teamRequest(userId: string | undefined, teamId: string | undefined) {
return await this.request("/teamRequest", {
async requestToJoinTeam(userId: string | undefined, teamId: string | undefined) {
return await this.request("/requestToJoinTeam", {
userId: userId,
teamId: teamId,
});
Expand All @@ -152,7 +152,7 @@ export default class ClientAPI {
number: number,
creator: string | undefined,
tbaId: undefined | string
) {
): Promise<Team> {
return await this.request("/createTeam", {
name: name,
number: number,
Expand All @@ -161,7 +161,7 @@ export default class ClientAPI {
});
}

async createSeason(name: string, year: number, teamId: string) {
async createSeason(name: string, year: number, teamId: string): Promise<Season> {
return await this.request("/createSeason", {
name: name,
year: year,
Expand Down Expand Up @@ -408,6 +408,10 @@ export default class ClientAPI {
return await this.request("/updatePicklist", { picklist });
}

async setOnboardingCompleted(userId: string) {
return await this.request("/setOnboardingCompleted", { userId });
}

async submitSubjectiveReport(report: SubjectiveReport, userId: string, teamId: string) {
return await this.request("/submitSubjectiveReport", { report, userId, teamId });
}
Expand Down
25 changes: 25 additions & 0 deletions lib/client/useDynamicState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Dispatch, SetStateAction, useState } from "react";

/**
* An alternative to useState that allows for the latest state to be easily retrieved.
*
* @param initialState
* @returns [ state, setState, getState ]. The first two elements are the same as useState, and the
* third element is a function that takes a function as a parameter. The parameter function takes the latest state as
* a parameter.
*/
export default function<T>(initialState?: T):
[T | undefined, Dispatch<SetStateAction<T | undefined>>, (func: (state: T | undefined) => void) => void] {
const [state, setState] = useState<T | undefined>(initialState);

return [
state,
setState,
(func: (state: T | undefined) => void) => {
setState((prevState) => {
func(prevState);
return prevState;
});
}
]
}
1 change: 0 additions & 1 deletion pages/[teamSlug]/[seasonSlug]/createComp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { GetServerSideProps } from "next";
import Container from "@/components/Container";
import Flex from "@/components/Flex";
import Card from "@/components/Card";
import Loading from "@/components/Loading";

const api = new ClientAPI("gearboxiscool");

Expand Down
4 changes: 2 additions & 2 deletions pages/[teamSlug]/createSeason.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { FaPlus } from "react-icons/fa";
import { create } from "domain";

const api = new ClientAPI("gearboxiscool");
const CurrentSeason = new Season("Crescendo", undefined, 2024);
const OffSeason = new Season("Offseason", undefined, 2024);
export const CurrentSeason = new Season("Crescendo", undefined, 2024);
export const OffSeason = new Season("Offseason", undefined, 2024);

type CreateSeasonProps = { team: Team; existingSeasons: Season[] };

Expand Down
214 changes: 214 additions & 0 deletions pages/onboarding.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import Container from "@/components/Container";
import Loading from "@/components/Loading";
import { Season, Team } from "@/lib/Types";
import ClientAPI from "@/lib/client/ClientAPI";
import { useCurrentSession } from "@/lib/client/useCurrentSession";
import useDynamicState from "@/lib/client/useDynamicState";
import { useRouter } from "next/router";
import { ChangeEvent, useEffect, useState } from "react";
import { CurrentSeason, OffSeason } from "./[teamSlug]/createSeason";

const api = new ClientAPI("gearboxiscool")

export default function Onboarding() {
const { session, status } = useCurrentSession();
const router = useRouter();

const [teamNumber, setTeamNumber, getTeamNumber] = useDynamicState<number | undefined>();
const [team, setTeam] = useState<Team>();
const [teamConfirmed, setTeamConfirmed] = useState<boolean>(false);

enum JoinRequestStatus {
NotRequested,
Requested,
Rejected,
CreatedTeam
}
const [joinRequestStatus, setJoinRequestStatus] = useState<JoinRequestStatus>(JoinRequestStatus.NotRequested);
const [season, setSeason] = useState<Season>(new Date().getMonth() < 6 ? CurrentSeason : OffSeason);
const [seasonCreated, setSeasonCreated] = useState<boolean>(false);
1

if ((session?.user?.onboardingComplete || session?.user?.teams.length === 0) ?? false)
router.push("/profile");

async function completeOnboarding(redirect: string = "/profile") {
if (!session?.user?._id) return;

api.setOnboardingCompleted(session?.user?._id);
router.push(redirect);
}

async function teamNumberChanged(e: ChangeEvent<HTMLInputElement>) {
const number = parseInt(e.target.value);
console.log("Changed team # to", number);
setTeamNumber(number);

if (number && !isNaN(number)) {
const team = await api.findTeamByNumber(number)
.then(team => team.name ? team : api.getTeamAutofillData(number));


getTeamNumber((num) => {
if (num !== number) return;

setTeam(team);
setJoinRequestStatus(team.requests?.includes(session?.user?._id ?? "") ?? false
? JoinRequestStatus.Requested : JoinRequestStatus.NotRequested);
});
}
else setTeam(undefined);
}

async function requestToJoinTeam() {
if (!session?.user?._id || !teamNumber) return;

setJoinRequestStatus(JoinRequestStatus.Requested);
await api.requestToJoinTeam(session?.user?._id, team?._id);
}

async function updateTeamRequestStatus() {
if (!session?.user?._id || !teamNumber) return;

let team = await api.findTeamByNumber(teamNumber);

const requestPending = team.requests?.includes(session?.user?._id ?? "") ?? false;
if (joinRequestStatus === JoinRequestStatus.Requested && (team?.users?.includes(session?.user?._id ?? "") ?? false))
completeOnboarding();

if (requestPending)
setJoinRequestStatus(JoinRequestStatus.Requested);
else if (joinRequestStatus === JoinRequestStatus.Requested)
setJoinRequestStatus(JoinRequestStatus.Rejected);
}

useEffect(() => { setInterval(updateTeamRequestStatus, 5000); }, []);

async function createTeam() {
if (!session?.user?._id || !teamNumber || !team?.name || !team.tbaId) return;

setTeam(await api.createTeam(team?.name, teamNumber, session?.user?._id, team?.tbaId));
setJoinRequestStatus(JoinRequestStatus.CreatedTeam);
}

async function createSeason() {
if (!session?.user?._id || !team?._id) return;

setSeason(await api.createSeason(season.name, season.year, team?._id));
setSeasonCreated(true);
}

return (
<Container requireAuthentication={true}>
{ !session?.user && <Loading size={64} /> }
<div className="w-full flex justify-center p-12">
<div className="card bg-base-200 w-2/3">
<div className="card-body flex flex-col justify-between">
<div> {/* This div is the main content, it's aligned with the top */}
<div className="card-title gap-0">
Welcome to<span className="text-accent ml-1">Gearbox</span>, {session?.user?.name?.split(' ')[0]}!
</div>
<div className="pb-6">
Before you can start on your scouting journey, there&apos;s a bit of set up to do. It will only take a minute.
</div>
{ !teamConfirmed || !team
? <div>
<div className="text-xl">
What team are you on?
</div>
<input type="number" defaultValue={teamNumber?.toString()} className="input input-bordered mt-2" placeholder="Team Number"
onChange={teamNumberChanged} />
{ team &&
<div>
{
team.name
? <div>
<div className="text-lg mt-2">
Team <span className="text-accent">{team.number}</span>
{" "}- <span className="text-secondary">{team.name}</span>. Is that right?
</div>
<button className="btn btn-primary mt-2" onClick={() => setTeamConfirmed(true)}>
Yes, that is the correct team.
</button>
</div>
: <div className="text-lg mt-2">
Hmmm. We couldn&apos;t find team <span className="text-accent">{team.number}</span>. Are you sure that&apos;s
the correct number?
</div>
}
</div>
}
</div>
: <div>
{ team?.users?.length > 0 ?? false
? <div>
{ joinRequestStatus === JoinRequestStatus.NotRequested
? <div>
<div>
Team <span className="text-accent">{team.number}</span> is already registered.
You&apos;ll need approval to join.
</div>
<button className="btn btn-primary mt-2" onClick={requestToJoinTeam}>
Request to join team {team.number}, {team.name}
</button>
</div>
: joinRequestStatus === JoinRequestStatus.Requested
? <div>
Your request to join team {team.number}, {team.name}, has been sent.
You will be redirected when it&apos;s approved.
</div>
: joinRequestStatus === JoinRequestStatus.CreatedTeam
? (
!seasonCreated
? <div>
<div>
Now, we need to create a season. Seasons are used to organize competitions.
</div>
<button className="btn btn-primary mt-2" onClick={createSeason}>
Create season: {season.name} ({season.year})
</button>
</div>
: <div>
<div>
Season created! Now, we just need to create a competition, then you&apos;re done!.<br/>
<br/>
If you have any further questions, don&apos;t hesitate to reach out on
<a className="link link-hover" href="https://discord.gg/ha7AnqxFDD">Discord</a>.
</div>
<button className="btn btn-primary mt-2"
onClick={() => completeOnboarding(`/${team.slug}/${season.slug}/createComp`)}>
Take me to the create competition page
</button>
</div>
)
: <div>
Your request to join team {team.number}, {team.name}, has been rejected.
Please check with your scouting lead.
</div>
}
</div>
: <div>
<div>
You&apos;re the first one here from team {team.number}, {team.name}.
</div>
<button className="btn btn-primary mt-2" onClick={createTeam}>
Create team {team.number}, {team.name}
</button>
</div>
}
{ joinRequestStatus !== JoinRequestStatus.CreatedTeam &&
<button className="btn btn-error mt-3" onClick={() => setTeamConfirmed(false)}>
I entered the wrong team number.
</button>
}
</div>
}
</div>
{ /* This button is at the bottom*/}
<button className="btn btn-ghost mt-10" onClick={() => completeOnboarding()}>I know what I&apos;m doing, let me skip onboarding.</button>
</div>
</div>
</div>
</Container>
);
}
Loading
Loading