diff --git a/apps/total-typescript/src/offer/survey/sorting-hat-2024.ts b/apps/total-typescript/src/offer/survey/sorting-hat-2024.ts new file mode 100644 index 000000000..dd172a2e7 --- /dev/null +++ b/apps/total-typescript/src/offer/survey/sorting-hat-2024.ts @@ -0,0 +1,234 @@ +import {QuizResource} from '@skillrecordings/types' + +export const sortingHat2024: QuizResource = { + title: 'What Makes A Wizard Quiz', + questions: { + quiz_2024_any_usage: { + question: "How often do you use 'any'?", + type: 'multiple-choice', + shuffleChoices: false, + choices: [ + { + answer: 'pragmatic~1', + label: 'You gotta do what you gotta do', + }, + { + answer: 'never~3', + label: 'Absolutely never', + }, + { + answer: 'intentional~5', + label: 'Whenever I like - I know all the tradeoffs', + }, + ], + }, + quiz_2024_types_vs_interfaces: { + question: + 'One of these statements about types vs interfaces is false. Which one?', + type: 'multiple-choice', + shuffleChoices: true, + choices: [ + { + answer: 'performance~5', + label: 'Interfaces are more performant', + }, + { + answer: 'index_signature~1', + label: 'Types have an implicit index signature', + }, + { + answer: 'unions~1', + label: "Interfaces can't be used to express union types", + }, + ], + }, + quiz_2024_npm_confidence: { + question: 'How confident are you at publishing to NPM?', + type: 'multiple-choice', + shuffleChoices: false, + choices: [ + { + answer: 'terrified~1', + label: 'It fills me with terror', + }, + { + answer: 'uncertain~2', + label: "I did it once and I'm pretty sure I did it wrong", + }, + { + answer: 'comfortable~3', + label: "I've done it a few times", + }, + { + answer: 'expert~5', + label: 'I eat NPM packages for breakfast', + }, + ], + }, + quiz_2024_object_keys: { + question: 'What type does Object.keys return?', + type: 'multiple-choice', + shuffleChoices: true, + choices: [ + { + answer: 'correct~5', + label: 'An array of strings', + }, + { + answer: 'incorrect~2', + label: + 'An array of strings, typed to the keys of the object passed in', + }, + { + answer: 'unknown~1', + label: "Don't know", + }, + ], + }, + quiz_2024_let_const_inference: { + question: "Do 'let' and 'const' infer differently in TypeScript?", + type: 'multiple-choice', + shuffleChoices: true, + choices: [ + { + answer: 'incorrect~2', + label: 'No, they behave the same', + }, + { + answer: 'correct~5', + label: 'Yes, they behave differently', + }, + { + answer: 'unknown~1', + label: 'No idea', + }, + ], + }, + quiz_2024_company_wizard: { + question: 'Are you the TypeScript wizard at your company?', + type: 'multiple-choice', + shuffleChoices: false, + choices: [ + { + answer: 'novice~1', + label: "I'm usually the one bothering the wizards", + }, + { + answer: 'intermediate~3', + label: 'I get by OK, but nothing special', + }, + { + answer: 'expert~5', + label: 'My colleagues know I can help fix their TS problems', + }, + ], + }, + quiz_2024_object_type: { + question: "Does TypeScript have 'open' or 'closed' objects?", + type: 'multiple-choice', + shuffleChoices: true, + choices: [ + { + answer: 'correct~5', + label: 'Open', + }, + { + answer: 'incorrect~2', + label: "Closed (sometimes called 'exact')", + }, + { + answer: 'unknown~1', + label: 'No clue', + }, + ], + }, + quiz_2024_generics_confidence: { + question: 'How confident are you with Generics?', + type: 'multiple-choice', + shuffleChoices: false, + choices: [ + { + answer: 'terrified~1', + label: '*breaks out in a cold sweat*', + }, + { + answer: 'basic~2', + label: 'Types are fine, but generic functions freak me out', + }, + { + answer: 'comfortable~3', + label: "I'm happy in my app's 'utils' folder", + }, + { + answer: 'advanced~4', + label: "I know what 'const T' does", + }, + { + answer: 'expert~5', + label: "I know what 'in/out T' does", + }, + ], + }, + quiz_2024_empty_object: { + question: "Do you know what's special about the empty object type: '{}'?", + type: 'multiple-choice', + shuffleChoices: true, + choices: [ + { + answer: 'wrong~1', + label: 'You can pass any type to it', + }, + { + answer: 'incorrect~2', + label: 'You can pass any object to it', + }, + { + answer: 'correct~5', + label: 'You can pass anything except null or undefined to it', + }, + { + answer: 'unknown~1', + label: 'What even is that', + }, + ], + }, + quiz_2024_declare_const: { + question: "Do you know what 'declare const' does?", + type: 'multiple-choice', + shuffleChoices: true, + choices: [ + { + answer: 'incorrect~1', + label: 'Declares a type in the global scope', + }, + { + answer: 'correct~5', + label: "Types a variable that doesn't exist at runtime", + }, + { + answer: 'unknown~0', + label: '¯\\_(ツ)_/¯', + }, + ], + }, + quiz_2024_untyped_js: { + question: 'How do you feel about using untyped JavaScript?', + type: 'multiple-choice', + shuffleChoices: false, + choices: [ + { + answer: 'js_lover~1', + label: 'Please, take me back to my untyped happy place', + }, + { + answer: 'mixed~2', + label: 'TypeScript is better, but I miss dynamic typing', + }, + { + answer: 'ts_lover~5', + label: "I'll never go back. TS is life.", + }, + ], + }, + }, +} diff --git a/apps/total-typescript/src/offer/survey/survey-config.ts b/apps/total-typescript/src/offer/survey/survey-config.ts index 6e6d42998..a6600e4a8 100644 --- a/apps/total-typescript/src/offer/survey/survey-config.ts +++ b/apps/total-typescript/src/offer/survey/survey-config.ts @@ -1,5 +1,7 @@ import {QuizResource} from '@skillrecordings/types' import {dataTypescript2024} from './data-typescript-2024' +import {sortingHat2024} from './sorting-hat-2024' +import {WIZARD_QUIZ_ID} from './wizard-quiz-config' export const surveyConfig = { answerSubmitUrl: process.env.NEXT_PUBLIC_CONVERTKIT_ANSWER_URL, @@ -69,4 +71,5 @@ export const surveyData: {[SURVEY_ID: string]: QuizResource} = { }, }, [TYPESCRIPT_2024_SURVEY_ID]: dataTypescript2024, + [WIZARD_QUIZ_ID]: sortingHat2024, } diff --git a/apps/total-typescript/src/offer/survey/survey-page.tsx b/apps/total-typescript/src/offer/survey/survey-page.tsx index 3f53b82dd..46fd87fc0 100644 --- a/apps/total-typescript/src/offer/survey/survey-page.tsx +++ b/apps/total-typescript/src/offer/survey/survey-page.tsx @@ -24,6 +24,7 @@ type SurveyPageProps = { isComplete: boolean showEmailQuestion: boolean onEmailSubmit: (email: string) => void + completionMessageComponent?: React.ReactNode } export const SurveyPage: React.FC = ({ @@ -35,6 +36,12 @@ export const SurveyPage: React.FC = ({ isComplete, showEmailQuestion, onEmailSubmit, + completionMessageComponent = ( +
+

Thank you for your responses!

+

Your answers have been recorded.

+
+ ), }) => { const handleAnswerSubmit = async (context: SurveyMachineContext) => { await handleSubmitAnswer(context) @@ -51,12 +58,7 @@ export const SurveyPage: React.FC = ({ } if (isComplete) { - return ( -
-

Thank you for your responses!

-

Your answers have been recorded.

-
- ) + return completionMessageComponent } return ( diff --git a/apps/total-typescript/src/offer/survey/wizard-quiz-config.ts b/apps/total-typescript/src/offer/survey/wizard-quiz-config.ts new file mode 100644 index 000000000..8e4dd6cc6 --- /dev/null +++ b/apps/total-typescript/src/offer/survey/wizard-quiz-config.ts @@ -0,0 +1,14 @@ +import {sortingHat2024} from './sorting-hat-2024' +import {SurveyConfig} from './survey-config' + +export const WIZARD_QUIZ_ID = 'wizard_quiz_2024' + +export const wizardQuizConfig: SurveyConfig = { + answerSubmitUrl: process.env.NEXT_PUBLIC_CONVERTKIT_ANSWER_URL, + afterCompletionMessages: { + neutral: { + default: 'Your magical prowess has been assessed...', + last: 'Your magical prowess has been assessed...', + }, + }, +} diff --git a/apps/total-typescript/src/pages/wizard-quiz.tsx b/apps/total-typescript/src/pages/wizard-quiz.tsx new file mode 100644 index 000000000..0ee1893e5 --- /dev/null +++ b/apps/total-typescript/src/pages/wizard-quiz.tsx @@ -0,0 +1,168 @@ +import * as React from 'react' +import {SurveyPage} from '../offer/survey/survey-page' +import {useSurveyPageOfferMachine} from '../offer/use-survey-page-offer-machine' +import {QuestionResource} from '@skillrecordings/types' +import {trpc} from '@/trpc/trpc.client' +import Layout from '@/components/app/layout' +import {sortingHat2024} from '../offer/survey/sorting-hat-2024' +import {wizardQuizConfig} from '../offer/survey/wizard-quiz-config' + +type WizardRank = { + title: string + description: string + minScore: number +} + +const wizardRanks: WizardRank[] = [ + { + title: 'Novice', + description: + 'You are beginning to grasp the fundamental scrolls of TypeScript', + minScore: 0, + }, + { + title: 'Apprentice', + description: + 'You can decipher most type runes, but the deepest mysteries elude you', + minScore: 20, + }, + { + title: 'Typeweaver', + description: + 'Your type incantations grow more powerful with each passing commit', + minScore: 35, + }, + { + title: 'Mage', + description: 'Few dare to challenge your command of advanced type sorcery', + minScore: 45, + }, + { + title: 'Wizard', + description: + 'You have transcended mere type checking. The compiler bends to your will.', + minScore: 55, + }, +] + +const calculateScore = (answers: Record) => { + return Object.values(answers).reduce((total, answer) => { + const score = parseInt(answer.split('~')[1]) || 0 + return total + score + }, 0) +} + +const getWizardRank = (score: number): WizardRank => { + return ( + [...wizardRanks].reverse().find((rank) => score >= rank.minScore) || + wizardRanks[0] + ) +} + +const CompletionMessage: React.FC<{answers: Record}> = ({ + answers, +}) => { + const score = calculateScore(answers) + const rank = getWizardRank(score) + const maxScore = Object.keys(sortingHat2024.questions).length * 5 + + return ( +
+
+

+ {rank.title} +

+
{rank.description}
+
+
+
+ Arcane TypeScript Power: {score} / {maxScore} +
+
+
+
+
+
+ ) +} + +const WIZARD_QUIZ_ID = 'wizard_quiz_2024' + +const WizardQuizPage: React.FC = () => { + const { + currentQuestion, + currentQuestionId, + isLoading, + isComplete, + isPresenting, + sendToMachine, + handleSubmitAnswer, + subscriber, + answers, + machineState, + } = useSurveyPageOfferMachine(WIZARD_QUIZ_ID) + + const answerSurveyMutation = trpc.convertkit.answerSurvey.useMutation() + const answerSurveyMultipleMutation = + trpc.convertkit.answerSurveyMultiple.useMutation() + const [email, setEmail] = React.useState(null) + + const handleEmailSubmit = async (email: string) => { + setEmail(email) + sendToMachine('EMAIL_COLLECTED') + } + + React.useEffect(() => { + if (isComplete && machineState.matches('offerComplete')) { + answerSurveyMultipleMutation.mutate({ + email: email || subscriber?.email_address, + answers, + surveyId: WIZARD_QUIZ_ID, + }) + } + }, [isComplete]) + + return ( + +
+ {isLoading ? ( +
Loading quiz...
+ ) : !currentQuestion && !isPresenting ? ( +
+ Quiz not available at this time. +
+ ) : ( + { + if (email || subscriber?.email_address) { + answerSurveyMutation.mutate({ + answer: context.answer, + question: context.currentQuestionId, + }) + } + await handleSubmitAnswer(context) + }} + surveyConfig={wizardQuizConfig} + sendToMachine={sendToMachine} + isComplete={isComplete} + showEmailQuestion={machineState.matches('collectEmail')} + onEmailSubmit={handleEmailSubmit} + completionMessageComponent={} + /> + )} +
+
+ ) +} + +export default WizardQuizPage