diff --git a/public/index.html b/public/index.html index a6f924aa..ad3dfac3 100644 --- a/public/index.html +++ b/public/index.html @@ -30,10 +30,10 @@ - + + To create a production bundle, use `npm run build` or `yarn build`. --> +
+ + diff --git a/public/manifest.json b/public/manifest.json index 2e13c649..e04b53f9 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -18,7 +18,7 @@ "sizes": "512x512" } ], - "start_url": ".", + "start_url": "index.html", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" diff --git a/public/serviceWorker.js b/public/serviceWorker.js new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Button.tsx b/src/components/Button.tsx index fa730d8e..04ebd711 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -35,6 +35,13 @@ export const Button = styledWithDefault( &:hover { border: 1px solid ${theme.colors.TEXT_GRAY}; } + &:disabled{ + background: ${theme.colors.MED_GRAY}; + + &:hover { + border: 1px solid ${theme.colors.MED_GRAY}; + } + } `} ${({ variant }) => diff --git a/src/components/GettingStartedModal/ChooseSetStep.tsx b/src/components/GettingStartedModal/ChooseSetStep.tsx new file mode 100644 index 00000000..08999278 --- /dev/null +++ b/src/components/GettingStartedModal/ChooseSetStep.tsx @@ -0,0 +1,111 @@ +import { ChangeEvent, FormEvent, ReactElement, useEffect , useState} from "react"; +import { NavigationButtons, Step, StepProps } from "."; +import { Button } from "../Button"; +import { + isPhoneticsPreference, + PhoneticsPreference, + PREFERENCE_LITERATES, +} from "../../state/reducers/phoneticsPreference"; +import { GROUPS, isGroupId } from "../../state/reducers/groupId"; +import { SectionHeading } from "../SectionHeading"; +import { CollectionDetails } from "../CollectionDetails"; + +import { collections, VocabSet } from "../../data/vocabSets"; +import { useUserStateContext } from "../../providers/UserStateProvider"; +import { TermsByProficiencyLevelChart } from "../TermsByProficiencyLevelChart"; + +export const ChooseSetStep: Step = { + title: "Choose your first set", + commitState: ({ collectionId }, {setUpstreamCollection}) => { + if (collectionId !== undefined){ + setUpstreamCollection(collectionId); + } + }, + Component: ChooseStepComponent, +}; + +function ChooseStepComponent({ + wizardState: { collectionId, groupId }, + setWizardState, + goToNextStep, + goToPreviousStep, +}: StepProps): ReactElement { + let canGoToNextStep = collectionId !== undefined; + function setWizardStateCollectionId( + newCollectionId: string + ) { + setWizardState((s) => ({ + ...s, + collectionId: newCollectionId, + })); + } + function onRadioChanged(e: ChangeEvent) { + const collectionId = e.target.value; + setWizardStateCollectionId(collectionId); + canGoToNextStep = true; + } + function onSubmit(e: FormEvent) { + e.preventDefault(); + if (collectionId && goToNextStep) goToNextStep(); + } + + + function totalTerms(vocab: VocabSet[]){ + var t = 0; + + vocab.map((vocabSet) => ( + t += vocabSet.terms.length + )) + + return t; + } + + useEffect(() => { + if (!collectionId) { + const defaultCollectionId = + groupId && isGroupId(groupId) + ? GROUPS[groupId].defaultCollectionId + : undefined; + if (defaultCollectionId) { + setWizardStateCollectionId(defaultCollectionId) + canGoToNextStep = true; + } + } + }, [collectionId]); + + return ( +
+ +

+ +
+
+ Select a collection + + {Object.values(collections).map((collection, idx) => ( +
+ + + + +
+ + ))} + +
+ + +
+ ); +} diff --git a/src/components/GettingStartedModal/GroupRegistrationStep.tsx b/src/components/GettingStartedModal/GroupRegistrationStep.tsx new file mode 100644 index 00000000..b8311241 --- /dev/null +++ b/src/components/GettingStartedModal/GroupRegistrationStep.tsx @@ -0,0 +1,100 @@ +import { ChangeEvent, FormEvent, useState } from "react"; +import { NavigationButtons, Step, StepProps } from "."; +import { GROUPS } from "../../state/reducers/groupId"; +import { Button } from "../Button"; + +export const GroupRegistrationStep: Step = { + title: "Personal Information", + Component: GroupRegistrationForm, + commitState: ({ groupId, email, whereFound }, { registerGroup, setUserEmail, setWhereFound }) => { + registerGroup(groupId); + setWhereFound(whereFound); + setUserEmail(email); + }, +}; + +export function GroupRegistrationForm({ + wizardState: { groupId , email, whereFound}, + setWizardState, + goToNextStep, + goToPreviousStep, + exitWizard +}: StepProps) { + const [enabled, setEnabled] = useState(groupId!=undefined && email !== ''); + + function onGroupIdChanged(e: ChangeEvent) { + const groupId = e.target.value; + setWizardState((s) => ({ ...s, groupId })); + setEnabled(email !== ''); + } + + function onEmailChanged(e: ChangeEvent) { + const email = e.target.value; + setWizardState((s) => ({ ...s, email })); + setEnabled(groupId !== undefined && email !== ''); + } + + function onWhereFoundChanged(e: ChangeEvent){ + const whereFound = e.target.value; + setWizardState((s) => ({ ...s, whereFound })); + setEnabled(whereFound !== undefined); + } + + function onSubmit(e: FormEvent) { + e.preventDefault(); + if (groupId && email && goToNextStep) goToNextStep(); + } + return ( + <> +

+ We're happy to have you here! Who are you, and how did you hear about us? +

+

+ After you've completed this, you're all set! Continue for advanced settings. +

+
+
+ + +
+
+ + +
+
+ Group + {Object.entries(GROUPS).map(([id, group]) => ( +
+ + +
+ ))} +
+ + +
+ + ); +} diff --git a/src/components/GettingStartedModal/PhoneticsStep.tsx b/src/components/GettingStartedModal/PhoneticsStep.tsx new file mode 100644 index 00000000..ce367da5 --- /dev/null +++ b/src/components/GettingStartedModal/PhoneticsStep.tsx @@ -0,0 +1,94 @@ +import { ChangeEvent, FormEvent, ReactElement, useEffect, useState } from "react"; +import { NavigationButtons, Step, StepProps } from "."; +import { Button } from "../Button"; +import { + isPhoneticsPreference, + PhoneticsPreference, + PREFERENCE_LITERATES, +} from "../../state/reducers/phoneticsPreference"; + +import { GROUPS, isGroupId } from "../../state/reducers/groupId"; + +export const PhoneticsStep: Step = { + /* + * Sets the phonetic preference to the wizard state. + */ + title: "Phonetics", + commitState: ({ phoneticsPreference }, { setPhoneticsPreference }) => { + if (phoneticsPreference) { setPhoneticsPreference(phoneticsPreference); } + }, + Component: PhoneticsStepComponent, +}; + +function PhoneticsStepComponent({ + /* + * Defines the phonetics step. Allows user to set phonetics preference, advance to the next step, or go to the previous step. Offers a default preference. + */ + wizardState: { phoneticsPreference, groupId }, + setWizardState, + goToNextStep, + goToPreviousStep, +}: StepProps): ReactElement { + const [enabled, setEnbabled] = useState(phoneticsPreference != undefined); + function setWizardStatePhoneticsPreference( + newPhoneticsPreference: PhoneticsPreference + ) { + setWizardState((s) => ({ + ...s, + phoneticsPreference: newPhoneticsPreference, + })); + } + function onPreferenceChanged(e: ChangeEvent) { + const phoneticsPreference = e.target.value; + if (isPhoneticsPreference(phoneticsPreference)) + setWizardStatePhoneticsPreference(phoneticsPreference); + setEnbabled(true); + } + function onSubmit(e: FormEvent) { + e.preventDefault(); + if (phoneticsPreference && goToNextStep) goToNextStep(); + } + + useEffect(() => { + if (!phoneticsPreference) { + const defaultPhoneticsPreference = + groupId && isGroupId(groupId) + ? GROUPS[groupId].phoneticsPreference + : undefined; + if (defaultPhoneticsPreference) { + setWizardStatePhoneticsPreference(defaultPhoneticsPreference); + setEnbabled(true); + } + } + }, [phoneticsPreference]); + + return ( +
+

Select your phonetics preference!

+ +
+
+ Phonetics preference + {Object.entries(PREFERENCE_LITERATES).map(([value, literate], i) => ( +
+ + +
+ ))} +
+ + +
+ ); +} diff --git a/src/components/GettingStartedModal/Preamble.tsx b/src/components/GettingStartedModal/Preamble.tsx new file mode 100644 index 00000000..f6341764 --- /dev/null +++ b/src/components/GettingStartedModal/Preamble.tsx @@ -0,0 +1,39 @@ +import { ReactElement } from "react"; +import { NavigationButtons, Step, StepProps } from "."; +import { Button } from "../Button"; + +export const Preamble: Step = { + /* + * Defines the preamble step. Welcomes users to the site. + */ + title: "Welcome to Cheroke Language Exercises!", + commitState: () => {}, + Component: FirstStepComponent, +}; + +function FirstStepComponent({ + /* + * The first component of the wizard. Advances to the next step or redirects to FAQ page. + */ + wizardState, + goToNextStep, + goToPreviousStep, + exitWizard +}: StepProps): ReactElement { + return ( +
+ This website is dedicated to helping users develop knowledge of the Cherokee language. +
    + Code of Conduct: +
  • Filler text
  • +
+ + FAQ + + +
+ ); +} diff --git a/src/components/GettingStartedModal/index.tsx b/src/components/GettingStartedModal/index.tsx new file mode 100644 index 00000000..d85ce7b2 --- /dev/null +++ b/src/components/GettingStartedModal/index.tsx @@ -0,0 +1,187 @@ +import { + Dispatch, + ReactElement, + ReactNode, + SetStateAction, + useState, +} from "react"; +import { Button } from "../Button"; +import { Modal } from "../Modal"; +import { PhoneticsStep } from "./PhoneticsStep"; +import { GroupRegistrationStep } from "./GroupRegistrationStep"; +import { PhoneticsPreference } from "../../state/reducers/phoneticsPreference"; +import { ChooseSetStep } from "./ChooseSetStep"; +import { Preamble } from "./Preamble"; +import { useUserStateContext } from "../../providers/UserStateProvider"; +import { UserInteractors } from "../../state/useUserState"; +import styled from "styled-components"; + +export const StepIndicator = styled.h4` + text-align: center +`; + +export interface WizardState { + // gives us information about the state of the entire wizard + groupId: string; + email: string; + whereFound: string; + phoneticsPreference?: PhoneticsPreference; + collectionId?: string; +} + +export interface StepProps { + // these props let us keep track of the user's data + // partial means any field can be undefined + wizardState: Partial; + setWizardState: Dispatch>>; + + // these functions let us navigate through the steps + // undefiend if on first step + goToPreviousStep?: () => void; + goToNextStep?: () => void; + exitWizard?: () => void; +} + +export interface Step { + title: string; + Component: (props: StepProps) => ReactElement; + /** + * This function says how we should actually update user state. + */ + commitState: (state: WizardState, interactors: UserInteractors) => void; +} + +/** + * This is the list of steps for the getting started modal. + * + */ +const steps: Step[] = [Preamble, GroupRegistrationStep, PhoneticsStep, ChooseSetStep]; + +export function GettingStartedModal() { + const userStateContext = useUserStateContext(); + + // keep track of which step of workflow we are on (start on first step) + const [stepNumber, setStepNumber] = useState(0); + // keep track of what data the user has filled out + const [wizardState, setWizardState] = useState>({ }); + + const [fullState, setFullState] = useState(null); + + function exitWizardNoAdvanced(){ + const { groupId, email, whereFound } = wizardState; + + // check if all required fields have been filled out + if (groupId && email && whereFound) { + // if all fields are filled out, update user state and exit the modal + const fullState: WizardState = { + groupId, + email, + whereFound, + }; + steps.forEach((step) => step.commitState(fullState, userStateContext)); + setFullState(fullState); + // TODO: perform any necessary cleanup or finalization + } else { + // if any required fields are missing, show an error message or prevent the user from exiting + alert("Please fill out all required fields."); + // alternatively, you can disable the exit button until all required fields are filled out + } + } + + /** + * Take all the data the user input and run actions for each step using it + */ + function exitWizardAtEnd() { + // any part of wizard state could be undefined, so unpack it all + const { groupId, email, phoneticsPreference, collectionId, whereFound} = wizardState; + // add checks here to make sure fields are defined + if (groupId !== undefined && email !== undefined && phoneticsPreference !== undefined && collectionId != undefined && whereFound !== undefined) { + // reassemble state here (without Partial<>) + const fullState: WizardState = { groupId, email, phoneticsPreference, collectionId, whereFound}; + // dispatch the actions for each step + steps.forEach((step) => step.commitState(fullState, userStateContext)); + } + else { + // if any required fields are missing, show an error message or prevent the user from exiting + alert("Please fill out all required fields."); + } + } + + // render current step of workflow + const currentStep = steps[stepNumber]; + return ( + + {/* Rendering a component from a variable! This is how we change the content from step to step */} + { //otherwise, decrement + setStepNumber(stepNumber - 1); + } + } + goToNextStep={() => { + const nextStep = stepNumber + 1; + if (nextStep < steps.length) { + setStepNumber(nextStep); + } else { + exitWizardAtEnd(); + } + }} + exitWizard={() => { + exitWizardNoAdvanced(); + }} + setWizardState={setWizardState} + wizardState={wizardState} + /> + +
+ {stepNumber + 1} / {steps.length} + +
+
+ ); +} + +export function NavigationButtons({ + /* + * Defines the buttons available to the user for steps of the Getting Started Modal. + */ + goToPreviousStep, + goToNextStep, + exitWizard, + disabled +}: Pick & { + disabled?: boolean; + children?: React.ReactNode; +}) { + return ( +
+ {goToPreviousStep && ( + + )} + {goToNextStep && ( + + )} + {exitWizard && ( + + )} +
+ ); +} diff --git a/src/components/GroupRegistrationModal.tsx b/src/components/GroupRegistrationModal.tsx deleted file mode 100644 index 15a62494..00000000 --- a/src/components/GroupRegistrationModal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { ChangeEvent, FormEvent, useEffect, useState } from "react"; -import styled from "styled-components"; -import { GROUPS, OPEN_BETA_ID } from "../state/reducers/groupId"; -import { useUserStateContext } from "../providers/UserStateProvider"; -import { Button } from "./Button"; -import { Modal } from "./Modal"; - -const StyledFormSection = styled.div` - margin: 32px; - p { - margin: 8px 0; - } -`; - -const StyledEmailInput = styled.input` - :invalid { - border-color: red; - } -`; - -export function GroupRegistrationModal() { - const { - config: { groupId: savedGroupId, userEmail: savedUserEmail }, - registerGroup, - setUserEmail: saveUserEmail, - } = useUserStateContext(); - const [groupId, setGroupId] = useState( - savedGroupId ?? OPEN_BETA_ID - ); - const [userEmail, setUserEmail] = useState(savedUserEmail ?? ""); - - function onRadioChanged(e: ChangeEvent) { - setGroupId(e.target.value); - } - - function onEmailChanged(e: ChangeEvent) { - setUserEmail(e.target.value); - } - - function onSubmit(e: FormEvent) { - e.preventDefault(); - if (groupId && userEmail) { - registerGroup(groupId); - saveUserEmail(userEmail); - } - } - - return ( - {}} title="Group registration"> -

We have a few questions to ask before you get started on the site.

-
- -

Are registering with any group that uses the app?

-
- Group - {Object.entries(GROUPS).map(([id, group]) => ( -
- - -
- ))} -
-
- -

- We also need to be able contact you with important updates on the - project. -

-
- - -
-
-

- Thank you so much! Please enjoy the website and contact a maintainer - if you have any feedback. -

-
-
-
-
- ); -} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 28c82def..0676f13e 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -53,26 +53,27 @@ export function Modal({ flexContent = false, }: { title: string; - close: () => void; + close?: () => void; children?: ReactNode; flexContent?: boolean; }) { - return createPortal( - <> - close()}> - + + return createPortal( + <> + close?.()}> +
-
-

{title}

- +
+

{title}

+ {close && } +
+
-
-
- {children} -
- , - modalContainer! - ); + {children} +
+ , + modalContainer! + ); } diff --git a/src/providers/UserStateProvider.tsx b/src/providers/UserStateProvider.tsx index e06863f5..4ad9af77 100644 --- a/src/providers/UserStateProvider.tsx +++ b/src/providers/UserStateProvider.tsx @@ -6,7 +6,7 @@ import React, { useEffect, } from "react"; import { useLocalStorage } from "react-use"; -import { GroupRegistrationModal } from "../components/GroupRegistrationModal"; +import { GettingStartedModal } from "../components/GettingStartedModal"; import { LoadingPage } from "../components/Loader"; import { useAuth } from "../firebase/AuthProvider"; import { @@ -140,12 +140,13 @@ function WrappedUserStateProvider({ const { state, interactors, dispatch } = useUserState({ storedUserState, initializationProps: { - leitnerBoxes: { - numBoxes: 6, + leitnerBoxes: { + numBoxes: 6, }, }, }); + var userNeedsSetup: boolean = (state.config.userEmail === null || state.config.groupId === null || state.config.whereFound === null) // sync segments of state independently useEffect(() => { setConfig(state.config); @@ -157,9 +158,7 @@ function WrappedUserStateProvider({ return ( {children} - {(state.config.userEmail === null || state.config.groupId === null) && ( - - )} + {(userNeedsSetup) && ()} ); } diff --git a/src/state/actions.ts b/src/state/actions.ts index 4592cf12..23cb69c6 100644 --- a/src/state/actions.ts +++ b/src/state/actions.ts @@ -62,6 +62,12 @@ export type HandleSetChangesAction = { type: "HANDLE_SET_CHANGES"; }; +export type SetWhereFound = { + type: "WHERE_FOUND"; + whereFound: string; + +} + // FIXME: I think 'preferences' could get moved into a separate part of the codebase so this doesn't keep getting longer export type SetPhoneticsPreferenceAction = { type: "SET_PHONETICS_PREFERENCE"; @@ -81,4 +87,5 @@ export type UserStateAction = | LessonsAction | HandleSetChangesAction | SetPhoneticsPreferenceAction - | SetUserEmailAction; + | SetUserEmailAction + | SetWhereFound; diff --git a/src/state/useUserState.test.tsx b/src/state/useUserState.test.tsx index 674470e3..a55d15af 100644 --- a/src/state/useUserState.test.tsx +++ b/src/state/useUserState.test.tsx @@ -119,6 +119,7 @@ describe("useUserState", () => { groupId: null, phoneticsPreference: null, userEmail: null, + whereFound: null, }, }, initializationProps: { @@ -212,6 +213,7 @@ describe("useUserState", () => { groupId: null, phoneticsPreference: null, userEmail: null, + whereFound: null, }, }); }); diff --git a/src/state/useUserState.ts b/src/state/useUserState.ts index 66737207..ba32afff 100644 --- a/src/state/useUserState.ts +++ b/src/state/useUserState.ts @@ -63,6 +63,8 @@ export interface UserConfig { sets: UserSetsState; /** The collection from which new sets should be pulled when the user is ready for new terms */ upstreamCollection: string | null; + /** Sets where the user fond the site*/ + whereFound: string | null; /** Group registration */ groupId: GroupId | null; /** Preference for how phonetics are shown */ @@ -91,6 +93,7 @@ interface MiscInteractors { registerGroup: (groupId: string) => void; setPhoneticsPreference: (newPreference: PhoneticsPreference) => void; setUserEmail: (newUserEmail: string) => void; + setWhereFound: (whereFound: string) => void; loadState: (state: LegacyUserState) => void; } @@ -125,6 +128,12 @@ function reducePhoneticsPreference( else return phoneticsPreference; } +function reduceWhereFound({config: {whereFound}}: UserState, action: UserStateAction + ): string | null{ + if (action.type === "WHERE_FOUND") return action.whereFound; + else return whereFound; +} + function reduceUserEmail( { config: { userEmail } }: UserState, action: UserStateAction @@ -152,6 +161,7 @@ function reduceUserState(state: UserState, action: UserStateAction): UserState { groupId: reduceGroupId(state, action), phoneticsPreference: reducePhoneticsPreference(state, action), userEmail: reduceUserEmail(state, action), + whereFound: reduceWhereFound(state, action), }, leitnerBoxes: reduceLeitnerBoxState(state, action), ephemeral: { @@ -168,6 +178,7 @@ function blankUserState(initializationProps: UserStateProps): UserState { groupId: null, phoneticsPreference: null, userEmail: null, + whereFound: null, }, ephemeral: { lessonCreationError: null, @@ -187,6 +198,7 @@ export function convertLegacyState(state: LegacyUserState): UserState { phoneticsPreference: state.phoneticsPreference ?? null, upstreamCollection: state.upstreamCollection ?? null, userEmail: null, + whereFound: null, }, leitnerBoxes: state.leitnerBoxes, ephemeral: { lessonCreationError: state.lessonCreationError ?? null }, @@ -271,6 +283,12 @@ export function useUserState(props: { }); } }, + setWhereFound(whereFound: string){ + dispatch({ + type: "WHERE_FOUND", + whereFound, + }) + }, loadState(state: LegacyUserState) { dispatch({ type: "LOAD_STATE",