├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── LICENSE
├── README.md
├── apps
└── website
│ ├── .env.example
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
│ ├── components.json
│ ├── components
│ ├── analytics.tsx
│ ├── button.tsx
│ ├── code-block.tsx
│ ├── demo.tsx
│ ├── external-link.tsx
│ ├── footer.tsx
│ ├── hero.tsx
│ ├── onboarding-skeleton.tsx
│ ├── onboarding
│ │ ├── creating-steps.tsx
│ │ ├── give-feedback-step.tsx
│ │ ├── install-library.tsx
│ │ ├── introduction-step.tsx
│ │ ├── onboarding-data.tsx
│ │ ├── onboarding-setup.tsx
│ │ ├── onboarding-step-completion.tsx
│ │ ├── onboarding-step.tsx
│ │ ├── thank-you.tsx
│ │ └── writing-onboarding-steps.tsx
│ ├── subtitle.tsx
│ └── ui
│ │ ├── button.tsx
│ │ ├── form.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── select.tsx
│ │ └── skeleton.tsx
│ ├── lib
│ ├── analytics.ts
│ ├── hooks.ts
│ └── utils.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ ├── next.svg
│ ├── onboarding-lib-logo.svg
│ └── vercel.svg
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── docs
├── onboarding-lib-banner.png
└── onboarding-lib-logo.png
├── package.json
├── packages
└── lib
│ ├── README.md
│ ├── eslint.config.js
│ ├── package.json
│ ├── src
│ ├── hooks.ts
│ ├── index.tsx
│ ├── types.ts
│ └── utils.ts
│ ├── tsconfig.json
│ └── vite.config.ts
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prettier.config.cjs
└── turbo.json
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | push:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | lint:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v3
17 | - run: corepack enable
18 | - uses: actions/setup-node@v3
19 | with:
20 | node-version: 16
21 | cache: 'pnpm'
22 |
23 | - name: 📦 Install dependencies
24 | run: pnpm install --frozen-lockfile
25 |
26 | - name: 🔠 Lint project
27 | run: pnpm lint
28 |
29 | test:
30 | runs-on: ubuntu-latest
31 |
32 | steps:
33 | - uses: actions/checkout@v3
34 | - run: corepack enable
35 | - uses: actions/setup-node@v3
36 | with:
37 | node-version: 16
38 | cache: 'pnpm'
39 |
40 | - name: 📦 Install dependencies
41 | run: pnpm install --frozen-lockfile
42 |
43 | - name: 🛠 Build project
44 | run: pnpm build
45 |
46 | - name: 💪 Test types
47 | run: pnpm test:types
48 |
49 | - name: 🧪 Test project
50 | run: pnpm test
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | node_modules/
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | dist/
13 |
14 | # misc
15 | .DS_Store
16 | .rollup.cache
17 | *.pem
18 |
19 | # debug
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
24 | # local env files
25 | .env*.local
26 |
27 | # typescript
28 | *.tsbuildinfo
29 | next-env.d.ts
30 |
31 | #turbo
32 | .turbo
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Neftic Oy
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | packages/lib/README.md
--------------------------------------------------------------------------------
/apps/website/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_POSTHOG_KEY="phc_...."
2 | NEXT_PUBLIC_POSTHOG_HOST="https://eu.posthog.com"
3 |
--------------------------------------------------------------------------------
/apps/website/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/apps/website/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/apps/website/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/apps/website/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useflytrap/onboarding_lib/83450f46d069427421bf5a1a3b2dfc7b861e03ad/apps/website/app/favicon.ico
--------------------------------------------------------------------------------
/apps/website/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --gray1: #fcfcfc;
7 | --gray2: #f9f9f9;
8 | --gray3: #f0f0f0;
9 | --gray4: #e8e8e8;
10 | --gray5: #e0e0e0;
11 | --gray6: #d9d9d9;
12 | --gray7: #cecece;
13 | --gray8: #bbbbbb;
14 | --gray9: #8d8d8d;
15 | --gray10: #838383;
16 | --gray11: #646464;
17 | --gray12: #202020;
18 | }
19 |
--------------------------------------------------------------------------------
/apps/website/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next"
2 | import { Inter } from "next/font/google"
3 |
4 | import "./globals.css"
5 | import { Toaster } from "sonner"
6 |
7 | import { PosthogProvider } from "@/components/analytics"
8 | import { Footer } from "@/components/footer"
9 |
10 | const inter = Inter({ subsets: ["latin"] })
11 |
12 | export const metadata: Metadata = {
13 | title: "ONBOARDING_LIB",
14 | description:
15 | "ONBOARDING_LIB is a tiny headless onboarding library with form validation, schema validation using Zod and persistance with unstorage.",
16 | }
17 |
18 | export default function RootLayout({
19 | children,
20 | }: Readonly<{
21 | children: React.ReactNode
22 | }>) {
23 | return (
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/apps/website/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image"
2 |
3 | import { Demo } from "@/components/demo"
4 | import { Footer } from "@/components/footer"
5 | import { Hero } from "@/components/hero"
6 | import { Subtitle } from "@/components/subtitle"
7 |
8 | export default function Home() {
9 | return (
10 |
11 |
12 |
13 |
14 | Onboarding Demo
15 |
16 |
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/apps/website/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "stone",
10 | "cssVariables": false,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/apps/website/components/analytics.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import posthog from "posthog-js"
4 | import { PostHogProvider as PHProvider } from "posthog-js/react"
5 |
6 | if (typeof window !== "undefined") {
7 | posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
8 | api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
9 | })
10 | }
11 |
12 | export function PosthogProvider({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode
16 | }>) {
17 | return {children}
18 | }
19 |
--------------------------------------------------------------------------------
/apps/website/components/button.tsx:
--------------------------------------------------------------------------------
1 | export function Button({ children, ...props }: any) {
2 | return (
3 |
8 | {children}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/apps/website/components/code-block.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { useCallback, useState } from "react"
4 | import copy from "copy-to-clipboard"
5 | import { AnimatePresence, MotionConfig, motion } from "framer-motion"
6 | import { Highlight } from "prism-react-renderer"
7 | import useMeasure from "react-use-measure"
8 | import { twMerge } from "tailwind-merge"
9 |
10 | const variants = {
11 | visible: { opacity: 1, scale: 1 },
12 | hidden: { opacity: 0, scale: 0.5 },
13 | }
14 |
15 | const theme = {
16 | plain: {
17 | color: "var(--gray12)",
18 | fontSize: 12,
19 | fontFamily: "var(--font-mono)",
20 | },
21 | styles: [
22 | {
23 | types: ["comment"],
24 | style: {
25 | color: "var(--gray9)",
26 | },
27 | },
28 | {
29 | types: ["atrule", "keyword", "attr-name", "selector"],
30 | style: {
31 | color: "var(--gray10)",
32 | },
33 | },
34 | {
35 | types: ["punctuation", "operator"],
36 | style: {
37 | color: "var(--gray9)",
38 | },
39 | },
40 | {
41 | types: ["class-name", "function", "tag"],
42 | style: {
43 | color: "var(--gray12)",
44 | },
45 | },
46 | ],
47 | }
48 |
49 | export const CodeBlock = ({
50 | children,
51 | initialHeight = 0,
52 | copyFromAnywhere,
53 | copyContent,
54 | }: {
55 | children: string
56 | initialHeight?: number
57 | copyFromAnywhere?: true
58 | copyContent?: string
59 | }) => {
60 | const [ref, bounds] = useMeasure()
61 | const [copying, setCopying] = useState(false)
62 |
63 | const onCopy = useCallback(() => {
64 | copy(copyContent ?? children)
65 | setCopying(true)
66 | setTimeout(() => {
67 | setCopying(false)
68 | }, 2000)
69 | }, [copyContent, children])
70 |
71 | return (
72 |
{
78 | if (copyFromAnywhere) {
79 | onCopy()
80 | }
81 | }}
82 | >
83 |
88 |
89 |
90 | {copying ? (
91 |
98 |
109 |
110 |
111 |
112 | ) : (
113 |
120 |
131 |
132 |
133 |
134 | )}
135 |
136 |
137 |
138 |
139 |
140 | {({ className, tokens, getLineProps, getTokenProps }) => (
141 |
145 |
146 |
147 | {tokens.map((line, i) => {
148 | const { key: lineKey, ...rest } = getLineProps({ line, key: i })
149 | return (
150 |
151 | {line.map((token, key) => {
152 | const { key: tokenKey, ...rest } = getTokenProps({
153 | token,
154 | key,
155 | })
156 | return
157 | })}
158 |
159 | )
160 | })}
161 |
162 |
163 | )}
164 |
165 |
166 | )
167 | }
168 |
--------------------------------------------------------------------------------
/apps/website/components/demo.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useEffect, useState } from "react"
4 | import { createOnboarding } from "onboarding_lib"
5 | import { createStorage } from "unstorage"
6 | import localStorageDriver from "unstorage/drivers/localstorage"
7 | import { z } from "zod"
8 |
9 | import { trackEvent } from "@/lib/analytics"
10 | import { OnboardingSkeleton } from "@/components/onboarding-skeleton"
11 | import { CreatingStepsStep } from "@/components/onboarding/creating-steps"
12 | import { GiveFeedbackStep } from "@/components/onboarding/give-feedback-step"
13 | import { InstallLibraryStep } from "@/components/onboarding/install-library"
14 | import { IntroductionStep } from "@/components/onboarding/introduction-step"
15 | import { OnboardingDataStep } from "@/components/onboarding/onboarding-data"
16 | import { OnboardingSetupStep } from "@/components/onboarding/onboarding-setup"
17 | import { OnboardingStepCompletionStep } from "@/components/onboarding/onboarding-step-completion"
18 | import { ThankYouStep } from "@/components/onboarding/thank-you"
19 |
20 | /**
21 | * This is the schema that will be used by `react-hook-form`. You can use it to define error messages, etc.
22 | * all very intuitively.
23 | */
24 | export const onboardingSchema = z.object({
25 | disappointment: z.enum(
26 | ["very-disappointed", "somewhat-disappointed", "not-disappointed"],
27 | { required_error: "Please fill in your disappointment level :)" }
28 | ),
29 | improvements: z.string({
30 | required_error: "Please help us improve ONBOARDING_LIB for you :)",
31 | }),
32 | })
33 |
34 | export function Demo() {
35 | const [isMounted, setIsMounted] = useState(false)
36 |
37 | useEffect(() => {
38 | setIsMounted(true)
39 | }, [])
40 |
41 | if (isMounted === false) {
42 | return
43 | }
44 |
45 | const storage = createStorage({
46 | driver: localStorageDriver({
47 | base: "demo-onboarding",
48 | }),
49 | })
50 |
51 | const { Onboarding, Step } = createOnboarding({
52 | schema: onboardingSchema,
53 | })
54 |
55 | return (
56 | {
62 | console.log("Completed")
63 | }}
64 | defaultValues={{
65 | improvements: "",
66 | }}
67 | >
68 | {
72 | trackEvent("onboarding:complete_introduction")
73 | }}
74 | />
75 | {
79 | trackEvent("onboarding:complete_installation")
80 | }}
81 | />
82 | {
86 | trackEvent("onboarding:complete_setup")
87 | }}
88 | />
89 | {
93 | trackEvent("onboarding:complete_creating_steps")
94 | }}
95 | />
96 | {
100 | trackEvent("onboarding:complete_on_complete")
101 | }}
102 | />
103 | {
109 | trackEvent("onboarding:complete_feedback")
110 | trackEvent("onboarding:submit_feedback", {
111 | $set: { disappointment, improvements },
112 | disappointment,
113 | improvements,
114 | })
115 | }}
116 | />
117 | {
121 | trackEvent("onboarding:complete_onboarding_data")
122 | }}
123 | />
124 |
125 |
126 | )
127 | }
128 |
--------------------------------------------------------------------------------
/apps/website/components/external-link.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link"
2 |
3 | export function ExternalLink(
4 | props: React.AnchorHTMLAttributes
5 | ) {
6 | return (
7 |
12 | {props.children}
13 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/apps/website/components/footer.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image"
2 |
3 | export function Footer() {
4 | return (
5 |
24 | )
25 | }
26 |
--------------------------------------------------------------------------------
/apps/website/components/hero.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image"
2 | import Link from "next/link"
3 |
4 | export function Hero() {
5 | return (
6 |
7 |
13 |
14 | A tiny headless onboarding library with form validation, schema
15 | validation using Zod and persistance with unstorage.
16 |
17 |
18 |
19 | Try out demo onboarding flow
20 |
21 |
25 |
26 | GitHub
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton"
2 |
3 | export function OnboardingSkeleton() {
4 | return (
5 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/creating-steps.tsx:
--------------------------------------------------------------------------------
1 | import { type OnboardingStepRenderProps } from "onboarding_lib"
2 |
3 | import { Subtitle } from "@/components/subtitle"
4 |
5 | import { CodeBlock } from "../code-block"
6 | import { onboardingSchema } from "../demo"
7 | import { OnboardingStepContainer } from "./onboarding-step"
8 |
9 | const onboardingStepPropDefinition = `export type OnboardingStepProps = {
10 | /**
11 | * The ID of this step. Should be unique.
12 | */
13 | stepId: string
14 | /**
15 | * The \`markAsCompleted\` value can be used to simply mark the step as completed, but can also
16 | * be used to for instance run side-effects, and proceed only when those have succeeded. If you return
17 | * \`false\`, the \`onMarkAsCompletedFailed\` function will be called.
18 | */
19 | markAsCompleted?:
20 | | ((data: z.infer) => Promise)
21 | | ((data: z.infer) => boolean)
22 | | boolean
23 | /**
24 | * This function will get called, when the user is trying to proceed to the next step (by calling the
25 | * \`next\` function), but the \`markAsCompleted\` returns false for this step.
26 | */
27 | onMarkAsCompletedFailed?: () => void
28 | /**
29 | * Whether this step can be skipped. If set to \`false\`, the value of the \`skip\` prop will be \`undefined\`.
30 | * @default true
31 | */
32 | skippable?: boolean
33 | /**
34 | * Whether this step should be marked as disabled. This value doesn't actually do anything at the moment.
35 | */
36 | disabled?: boolean
37 | /**
38 | * Enable the validation of the form fields provided, when \`next\` is called. If no form fields are provided, validation won't happen until the final submit.
39 | */
40 | validateFormFields?: (keyof z.infer)[]
41 | /**
42 | * Run code when this step has completed. Useful for tracking onboarding conversion rates using product analytics tools.
43 | */
44 | onStepCompleted?: (data: z.infer) => void | Promise
45 | /**
46 | * The function that will be called to render your onboarding step component.
47 | * @returns A React component
48 | */
49 | render: (values: OnboardingStepRenderProps) => ReactNode
50 | }
51 | `
52 |
53 | // @todo: fetch this from the library code after i've added proper docs to them
54 | const onboardingStepRenderPropDefinition = `export type OnboardingStepRenderProps = {
55 | /**
56 | * Identifier for the onboarding. This will be used to separate the data from possible other onboarding flows.
57 | */
58 | id: string
59 | storage: Storage
60 | schema: T
61 | userId: string
62 | onCompleted?: (data: z.infer) => void
63 | onInvalid?: SubmitErrorHandler
64 | /**
65 | * Amount of milliseconds to debounce before saving the values to the provided \`Storage\`.
66 | * @default 500 ms
67 | */
68 | storageDebounceDelay?: number
69 | previous?: () => void
70 | next?: (skipped?: boolean) => void
71 | skip?: () => void
72 | goto: (stepId: string) => void
73 | form: UseFormReturn
74 | currentStepId: string
75 | completedStepIds: string[]
76 | stepId: string
77 | isCurrentStep: boolean
78 | isMarkedAsCompleted: boolean
79 | }
80 | `
81 |
82 | const validateFormFieldsExample = ` `
87 |
88 | const markAsCompletedExample = `) => {
93 | return data.disappointment === "very-disappointed"
94 | }}
95 | />
96 | `
97 |
98 | export function CreatingStepsStep(
99 | props: OnboardingStepRenderProps
100 | ) {
101 | return (
102 |
103 |
104 |
Define your step options
105 |
106 | Each step can be defined with different options.
107 |
108 |
109 | {onboardingStepPropDefinition}
110 |
111 |
112 |
The render prop
113 |
114 | We use a `render` prop to render the onboarding steps. Here's the
115 | props that get passed to the `render` function.
116 |
117 |
118 | {onboardingStepRenderPropDefinition}
119 |
120 |
121 |
Enforcing form valudation
122 |
123 | By default, the form only gets submitted when a submit button is
124 | clicked, and not between step transitions. To validate certain form
125 | fields, you can pass in an array to `validateFormFields` prop, with
126 | the keys of the fields you want to validate.
127 |
128 |
129 | {validateFormFieldsExample}
130 |
131 |
132 |
Running side-effects / marking a step as completed
133 |
134 | For marking a step as completed, or optionally running side-effects
135 | before letting the user continue, the `markAsCompleted` prop can be
136 | used. Your onboarding form data will get passed to the function, and
137 | you can use it to run async side-effects, or just validate the
138 | existance of form data.
139 |
140 | In the below example, the step only gets marked as completed, once the
141 | user has said that they would be "very-disappointed" without
142 | ONBOARDING_LIB ;)
143 |
144 |
145 | {markAsCompletedExample}
146 |
147 | )
148 | }
149 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/give-feedback-step.tsx:
--------------------------------------------------------------------------------
1 | import type { OnboardingStepRenderProps } from "onboarding_lib"
2 |
3 | import {
4 | FormControl,
5 | FormField,
6 | FormItem,
7 | FormLabel,
8 | FormMessage,
9 | } from "@/components/ui/form"
10 | import { Input } from "@/components/ui/input"
11 | import {
12 | Select,
13 | SelectContent,
14 | SelectItem,
15 | SelectTrigger,
16 | SelectValue,
17 | } from "@/components/ui/select"
18 | import { onboardingSchema } from "@/components/demo"
19 | import { OnboardingStepContainer } from "@/components/onboarding/onboarding-step"
20 | import { Subtitle } from "@/components/subtitle"
21 |
22 | export function GiveFeedbackStep(
23 | props: OnboardingStepRenderProps
24 | ) {
25 | return (
26 |
30 |
31 |
32 | Please help us improve `ONBOARDING_LIB` by answering some questions.
33 |
34 |
35 | This will be used with `react-hook-form`.
36 |
37 |
38 |
39 | (
43 |
44 |
45 | How disappointed would you be if you could no longer use
46 | ONBOARDING_LIB?
47 |
48 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | Very disappointed
61 |
62 |
63 | Somewhat disappointed
64 |
65 |
66 | Not disappointed
67 |
68 |
69 |
70 |
71 |
72 | )}
73 | />
74 |
75 | (
79 |
80 | How can we improve ONBOARDING_LIB for you?
81 |
82 |
83 |
84 | )}
85 | />
86 |
87 | )
88 | }
89 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/install-library.tsx:
--------------------------------------------------------------------------------
1 | import type { OnboardingStepRenderProps } from "onboarding_lib"
2 |
3 | import { CodeBlock } from "../code-block"
4 | import { onboardingSchema } from "../demo"
5 | import { OnboardingStepContainer } from "./onboarding-step"
6 |
7 | export function InstallLibraryStep(
8 | props: OnboardingStepRenderProps
9 | ) {
10 | return (
11 |
12 |
16 | $ npm install onboarding_lib
17 |
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/introduction-step.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { type OnboardingStepRenderProps } from "onboarding_lib"
3 |
4 | import { ExternalLink } from "@/components/external-link"
5 | import { Subtitle } from "@/components/subtitle"
6 |
7 | import { onboardingSchema } from "../demo"
8 | import { OnboardingStepContainer } from "./onboarding-step"
9 |
10 | export function IntroductionStep(
11 | props: OnboardingStepRenderProps
12 | ) {
13 | return (
14 |
15 |
16 |
A small introduction to ONBOARDING_LIB
17 |
18 | ONBOARDING_LIB is a onboarding library for React apps, that makes it
19 | easy to implement fully accessible, customizable and persisted
20 | onboarding flows.
21 |
22 |
23 |
24 |
25 |
Persistance using unstorage
26 |
27 | We use the great{" "}
28 |
29 | unstorage
30 | {" "}
31 | library from UnJS to handle persistance. This allows you to easily
32 | save your end-users' onboarding data on any driver such as
33 | LocalStorage, Redis, Netlify Blobs, Memory or the filesystem. See all
34 | of the supported drivers{" "}
35 |
36 | here.
37 |
38 |
39 |
40 |
41 |
42 |
Forms using `react-hook-form`
43 |
44 | ONBOARDING_LIB is built on top of{" "}
45 |
46 | react-hook-form
47 | {" "}
48 | to provide intuitive and accessible form handling in your onboarding
49 | flows. Using `react-hook-form`, we can define our onboarding schemas
50 | in Zod.
51 |
52 |
53 |
54 |
55 |
Walkthrough of ONBOARDING_LIB
56 |
57 | What follows behind the "Next" button is an onboarding flow,
58 | that will walk you though the ONBOARDING_LIB library, and show how we
59 | built this very onboarding. Very meta, right?
60 |
61 |
62 |
63 | )
64 | }
65 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/onboarding-data.tsx:
--------------------------------------------------------------------------------
1 | import type { OnboardingStepRenderProps } from "onboarding_lib"
2 |
3 | import { CodeBlock } from "@/components/code-block"
4 | import { Subtitle } from "@/components/subtitle"
5 |
6 | import { onboardingSchema } from "../demo"
7 | import { OnboardingStepContainer } from "./onboarding-step"
8 |
9 | export function OnboardingDataStep(
10 | props: OnboardingStepRenderProps
11 | ) {
12 | return (
13 |
14 | Here is your onboarding data
15 | {JSON.stringify(props.form.getValues(), null, 2)}
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/onboarding-setup.tsx:
--------------------------------------------------------------------------------
1 | import { type OnboardingStepRenderProps } from "onboarding_lib"
2 |
3 | import { Subtitle } from "@/components/subtitle"
4 |
5 | import { CodeBlock } from "../code-block"
6 | import { onboardingSchema } from "../demo"
7 | import { OnboardingStepContainer } from "./onboarding-step"
8 |
9 | const onboardingZodSchemaCode = `/**
10 | * This is the schema that will be used by \`react-hook-form\`. You can use it to define error messages, etc.
11 | * all very intuitively.
12 | */
13 | export const onboardingSchema = z.object({
14 | disappointment: z.enum(
15 | ["very-disappointed", "somewhat-disappointed", "not-disappointed"],
16 | { required_error: "Please fill in your disappointment level :)" }
17 | ),
18 | improvements: z.string({
19 | required_error: "Please help us improve ONBOARDING_LIB for you :)",
20 | }),
21 | })`
22 |
23 | const createOnboardingCode = `const { Onboarding, Step } = createOnboarding({
24 | schema: onboardingSchema,
25 | })
26 |
27 | return (
28 | {
34 | console.log("Completed")
35 | }}
36 | >
37 |
38 |
39 |
40 |
45 |
46 |
51 |
52 |
53 | )
54 | `
55 |
56 | export function OnboardingSetupStep(
57 | props: OnboardingStepRenderProps
58 | ) {
59 | return (
60 |
64 |
65 |
Define your form data with Zod
66 |
67 | This will be used with `react-hook-form`.
68 |
69 |
70 | {onboardingZodSchemaCode}
71 |
72 |
73 |
Create your onboarding instance
74 |
75 | This gives you the `Onboarding` wrapper component as well as a `Step`
76 | component with type-safety with `react-hook-form` forms, and the data
77 | in the onboarding. This will be used with `react-hook-form`.
78 |
79 |
80 | {createOnboardingCode}
81 |
82 | As you can see, the above code is the code used by this very onboarding
83 | flow. Let's dive deeper into how to define steps.
84 |
85 |
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/onboarding-step-completion.tsx:
--------------------------------------------------------------------------------
1 | import type { OnboardingStepRenderProps } from "onboarding_lib"
2 |
3 | import { Subtitle } from "@/components/subtitle"
4 |
5 | import { CodeBlock } from "../code-block"
6 | import { onboardingSchema } from "../demo"
7 | import { OnboardingStepContainer } from "./onboarding-step"
8 |
9 | const stepWithOnCompletedCode = `export function Onboarding() {
10 | return (
11 | {
17 | console.log("Completed")
18 | }}
19 | >
20 | {
25 | // Track analytics etc.
26 | }}
27 | />
28 |
29 | )
30 | }`
31 |
32 | export function OnboardingStepCompletionStep(
33 | props: OnboardingStepRenderProps
34 | ) {
35 | return (
36 |
40 |
41 |
Define your step completion handler
42 |
43 | To create conversion funnels for your onboarding flows, you can use
44 | the `onStepCompleted` prop as shown below. If the step has
45 | `validateFormFields` values, those will be validated upon calling the
46 | `next` function, after which the callback will be called.
47 |
48 |
49 | {stepWithOnCompletedCode}
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/onboarding-step.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react"
2 | import { CheckCircleIcon } from "@heroicons/react/24/outline"
3 | import { CheckCircleIcon as CheckCircleIconSolid } from "@heroicons/react/24/solid"
4 | import { AnimatePresence, motion } from "framer-motion"
5 | import type { OnboardingStepRenderProps } from "onboarding_lib"
6 |
7 | import { Button } from "@/components/ui/button"
8 | import { Subtitle } from "@/components/subtitle"
9 |
10 | import { onboardingSchema } from "../demo"
11 |
12 | const variants = {
13 | visible: { y: 0, opacity: 1 },
14 | hidden: { y: 10, opacity: 0 },
15 | }
16 |
17 | const variantsOut = {
18 | visible: { y: 0, opacity: 1 },
19 | hidden: { y: -10, opacity: 0 },
20 | }
21 |
22 | export function OnboardingStepContainer({
23 | stepId,
24 | next,
25 | previous,
26 | skip,
27 | isCurrentStep,
28 | children,
29 | title,
30 | goto,
31 | isMarkedAsCompleted,
32 | }: OnboardingStepRenderProps & {
33 | children: ReactNode
34 | title: string
35 | }) {
36 | return (
37 |
38 |
goto(stepId)}
41 | >
42 |
43 | {isMarkedAsCompleted && (
44 |
52 |
53 |
54 | )}
55 | {isMarkedAsCompleted === false && (
56 |
64 |
65 |
66 | )}
67 |
68 |
{title}
69 |
70 |
71 |
80 |
81 | {children}
82 |
83 | {previous && (
84 |
85 | Back
86 |
87 | )}
88 | {next && (
89 | next()}>
90 | Next
91 |
92 | )}
93 | {skip && (
94 |
95 | Skip
96 |
97 | )}
98 |
99 |
100 |
101 |
102 | )
103 | }
104 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/thank-you.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { type OnboardingStepRenderProps } from "onboarding_lib"
3 |
4 | import { ExternalLink } from "@/components/external-link"
5 | import { Subtitle } from "@/components/subtitle"
6 |
7 | import { onboardingSchema } from "../demo"
8 | import { OnboardingStepContainer } from "./onboarding-step"
9 |
10 | export function ThankYouStep(
11 | props: OnboardingStepRenderProps
12 | ) {
13 | return (
14 |
15 |
16 |
Thank you for checking out ONBOARDING_LIB
17 |
18 | Thank you for checking us out, and giving feedback. We really hope to
19 | make this a really solid onboarding library, so people don't have
20 | to write this same flimsy code over and over again. Please{" "}
21 | open an issue on GitHub if you
22 | find any problems.
23 |
24 |
25 |
26 |
27 |
PS. Got production bugs?
28 |
29 | We're building Flytrap, a debugging tool that helps you find the
30 | root causes of your production bugs fast. You can{" "}
31 |
32 | get started for free.
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/apps/website/components/onboarding/writing-onboarding-steps.tsx:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useflytrap/onboarding_lib/83450f46d069427421bf5a1a3b2dfc7b861e03ad/apps/website/components/onboarding/writing-onboarding-steps.tsx
--------------------------------------------------------------------------------
/apps/website/components/subtitle.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react"
2 |
3 | export function Subtitle({ children, ...props }: { children: ReactNode }) {
4 | return (
5 |
6 | {children}
7 |
8 | )
9 | }
10 |
--------------------------------------------------------------------------------
/apps/website/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-stone-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-stone-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-stone-900 text-stone-50 shadow hover:bg-stone-900/90 dark:bg-stone-50 dark:text-stone-900 dark:hover:bg-stone-50/90",
14 | destructive:
15 | "bg-red-500 text-stone-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-stone-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-stone-200 bg-white shadow-sm hover:bg-stone-100 hover:text-stone-900 dark:border-stone-800 dark:bg-stone-950 dark:hover:bg-stone-800 dark:hover:text-stone-50",
18 | secondary:
19 | "bg-stone-100 text-stone-900 shadow-sm hover:bg-stone-100/80 dark:bg-stone-800 dark:text-stone-50 dark:hover:bg-stone-800/80",
20 | ghost:
21 | "hover:bg-stone-100 hover:text-stone-900 dark:hover:bg-stone-800 dark:hover:text-stone-50",
22 | link: "text-stone-900 underline-offset-4 hover:underline dark:text-stone-50",
23 | },
24 | size: {
25 | default: "h-9 px-4 py-2",
26 | sm: "h-8 rounded-md px-3 text-xs",
27 | lg: "h-10 rounded-md px-8",
28 | icon: "h-9 w-9",
29 | },
30 | },
31 | defaultVariants: {
32 | variant: "default",
33 | size: "default",
34 | },
35 | }
36 | )
37 |
38 | export interface ButtonProps
39 | extends React.ButtonHTMLAttributes,
40 | VariantProps {
41 | asChild?: boolean
42 | }
43 |
44 | const Button = React.forwardRef(
45 | ({ className, variant, size, asChild = false, ...props }, ref) => {
46 | const Comp = asChild ? Slot : "button"
47 | return (
48 |
53 | )
54 | }
55 | )
56 | Button.displayName = "Button"
57 |
58 | export { Button, buttonVariants }
59 |
--------------------------------------------------------------------------------
/apps/website/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
142 | )
143 | })
144 | FormDescription.displayName = "FormDescription"
145 |
146 | const FormMessage = React.forwardRef<
147 | HTMLParagraphElement,
148 | React.HTMLAttributes
149 | >(({ className, children, ...props }, ref) => {
150 | const { error, formMessageId } = useFormField()
151 | const body = error ? String(error?.message) : children
152 |
153 | if (!body) {
154 | return null
155 | }
156 |
157 | return (
158 |
167 | {body}
168 |
169 | )
170 | })
171 | FormMessage.displayName = "FormMessage"
172 |
173 | export {
174 | useFormField,
175 | Form,
176 | FormItem,
177 | FormLabel,
178 | FormControl,
179 | FormDescription,
180 | FormMessage,
181 | FormField,
182 | }
183 |
--------------------------------------------------------------------------------
/apps/website/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/apps/website/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/apps/website/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import {
5 | CaretSortIcon,
6 | CheckIcon,
7 | ChevronDownIcon,
8 | ChevronUpIcon,
9 | } from "@radix-ui/react-icons"
10 | import * as SelectPrimitive from "@radix-ui/react-select"
11 |
12 | import { cn } from "@/lib/utils"
13 |
14 | const Select = SelectPrimitive.Root
15 |
16 | const SelectGroup = SelectPrimitive.Group
17 |
18 | const SelectValue = SelectPrimitive.Value
19 |
20 | const SelectTrigger = React.forwardRef<
21 | React.ElementRef,
22 | React.ComponentPropsWithoutRef
23 | >(({ className, children, ...props }, ref) => (
24 | span]:line-clamp-1 dark:border-stone-800 dark:ring-offset-stone-950 dark:placeholder:text-stone-400 dark:focus:ring-stone-300",
28 | className
29 | )}
30 | {...props}
31 | >
32 | {children}
33 |
34 |
35 |
36 |
37 | ))
38 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
39 |
40 | const SelectScrollUpButton = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
52 |
53 |
54 | ))
55 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
56 |
57 | const SelectScrollDownButton = React.forwardRef<
58 | React.ElementRef,
59 | React.ComponentPropsWithoutRef
60 | >(({ className, ...props }, ref) => (
61 |
69 |
70 |
71 | ))
72 | SelectScrollDownButton.displayName =
73 | SelectPrimitive.ScrollDownButton.displayName
74 |
75 | const SelectContent = React.forwardRef<
76 | React.ElementRef,
77 | React.ComponentPropsWithoutRef
78 | >(({ className, children, position = "popper", ...props }, ref) => (
79 |
80 |
91 |
92 |
99 | {children}
100 |
101 |
102 |
103 |
104 | ))
105 | SelectContent.displayName = SelectPrimitive.Content.displayName
106 |
107 | const SelectLabel = React.forwardRef<
108 | React.ElementRef,
109 | React.ComponentPropsWithoutRef
110 | >(({ className, ...props }, ref) => (
111 |
116 | ))
117 | SelectLabel.displayName = SelectPrimitive.Label.displayName
118 |
119 | const SelectItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | SelectItem.displayName = SelectPrimitive.Item.displayName
140 |
141 | const SelectSeparator = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef
144 | >(({ className, ...props }, ref) => (
145 |
150 | ))
151 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
152 |
153 | export {
154 | Select,
155 | SelectGroup,
156 | SelectValue,
157 | SelectTrigger,
158 | SelectContent,
159 | SelectLabel,
160 | SelectItem,
161 | SelectSeparator,
162 | SelectScrollUpButton,
163 | SelectScrollDownButton,
164 | }
165 |
--------------------------------------------------------------------------------
/apps/website/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
15 | )
16 | }
17 |
18 | export { Skeleton }
19 |
--------------------------------------------------------------------------------
/apps/website/lib/analytics.ts:
--------------------------------------------------------------------------------
1 | import posthog from "posthog-js"
2 |
3 | type EventMap = {
4 | "onboarding:complete_introduction": undefined
5 | "onboarding:complete_installation": undefined
6 | "onboarding:complete_setup": undefined
7 | "onboarding:complete_creating_steps": undefined
8 | "onboarding:complete_on_complete": undefined
9 | "onboarding:complete_feedback": undefined
10 | "onboarding:complete_onboarding_data": undefined
11 | "onboarding:submit_feedback": {
12 | disappointment: string
13 | improvements: string
14 | $set: {
15 | disappointment: string
16 | improvements: string
17 | }
18 | }
19 | }
20 |
21 | // Fully type-safe event capturing, NICE!
22 | export function createTrackingFunction<
23 | EventMap extends Record | undefined>
24 | >() {
25 | return {
26 | trackEvent: async (
27 | eventName: K,
28 | ...[properties]: EventMap[K] extends undefined ? [] : [EventMap[K]]
29 | ) => {
30 | posthog.capture(eventName as string, properties)
31 | },
32 | }
33 | }
34 |
35 | export const trackEvent = createTrackingFunction().trackEvent
36 |
--------------------------------------------------------------------------------
/apps/website/lib/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef } from "react"
2 |
3 | /**
4 | * Custom hook for determining if the component is currently mounted.
5 | * @returns {() => boolean} A function that returns a boolean value indicating whether the component is mounted.
6 | * @see [Documentation](https://usehooks-ts.com/react-hook/use-is-mounted)
7 | * @example
8 | * const isComponentMounted = useIsMounted();
9 | * // Use isComponentMounted() to check if the component is currently mounted before performing certain actions.
10 | */
11 | export function useIsMounted(): () => boolean {
12 | const isMounted = useRef(false)
13 |
14 | useEffect(() => {
15 | isMounted.current = true
16 |
17 | return () => {
18 | isMounted.current = false
19 | }
20 | }, [])
21 |
22 | return useCallback(() => isMounted.current, [])
23 | }
24 |
--------------------------------------------------------------------------------
/apps/website/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/apps/website/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: 'https',
7 | hostname: 'avatars.githubusercontent.com',
8 | pathname: '/**'
9 | },
10 | {
11 | protocol: 'https',
12 | hostname: 'www.useflytrap.com',
13 | pathname: '/**'
14 | },
15 | ]
16 | }
17 | };
18 |
19 | export default nextConfig;
20 |
--------------------------------------------------------------------------------
/apps/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "website",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@heroicons/react": "^2.1.1",
13 | "@hookform/resolvers": "^3.3.4",
14 | "@radix-ui/react-icons": "^1.3.0",
15 | "@radix-ui/react-label": "^2.0.2",
16 | "@radix-ui/react-select": "^2.0.0",
17 | "@radix-ui/react-slot": "^1.0.2",
18 | "class-variance-authority": "^0.7.0",
19 | "copy-to-clipboard": "^3.3.3",
20 | "framer-motion": "^11.0.3",
21 | "next": "14.1.0",
22 | "onboarding_lib": "workspace:*",
23 | "posthog-js": "^1.110.0",
24 | "prism-react-renderer": "^2.3.1",
25 | "react": "^18",
26 | "react-confetti-explosion": "^2.1.2",
27 | "react-dom": "^18",
28 | "react-hook-form": "^7.49.3",
29 | "react-use-measure": "^2.1.1",
30 | "sonner": "^1.3.1",
31 | "tailwind-merge": "^2.2.1",
32 | "tailwindcss-animate": "^1.0.7",
33 | "unstorage": "^1.10.1",
34 | "zod": "^3.22.4"
35 | },
36 | "devDependencies": {
37 | "@types/node": "^20",
38 | "@types/react": "^18",
39 | "@types/react-dom": "^18",
40 | "autoprefixer": "^10.4.17",
41 | "clsx": "^2.1.0",
42 | "eslint": "^8",
43 | "eslint-config-next": "14.1.0",
44 | "postcss": "^8.4.33",
45 | "tailwindcss": "^3.4.1",
46 | "typescript": "^5"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/website/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/website/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/public/onboarding-lib-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/apps/website/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/website/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | keyframes: {
22 | "accordion-down": {
23 | from: { height: "0" },
24 | to: { height: "var(--radix-accordion-content-height)" },
25 | },
26 | "accordion-up": {
27 | from: { height: "var(--radix-accordion-content-height)" },
28 | to: { height: "0" },
29 | },
30 | },
31 | animation: {
32 | "accordion-down": "accordion-down 0.2s ease-out",
33 | "accordion-up": "accordion-up 0.2s ease-out",
34 | },
35 | },
36 | },
37 | plugins: [require("tailwindcss-animate")],
38 | } satisfies Config
39 |
40 | export default config
41 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/docs/onboarding-lib-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useflytrap/onboarding_lib/83450f46d069427421bf5a1a3b2dfc7b861e03ad/docs/onboarding-lib-banner.png
--------------------------------------------------------------------------------
/docs/onboarding-lib-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/useflytrap/onboarding_lib/83450f46d069427421bf5a1a3b2dfc7b861e03ad/docs/onboarding-lib-logo.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "root",
3 | "version": "0.0.1",
4 | "description": "A tiny headless onboarding library with form validation, schema validation using Zod and persistance with unstorage.",
5 | "repository": "useflytrap/onboarding_lib",
6 | "author": "Rasmus ",
7 | "license": "MIT",
8 | "scripts": {
9 | "dev": "turbo dev",
10 | "build": "turbo build",
11 | "test": "turbo test",
12 | "lint": "turbo lint",
13 | "release": "turbo release",
14 | "format:write": "prettier --write \"**/*.{ts,tsx,mdx}\" --cache",
15 | "format:check": "prettier --check \"**/*.{ts,tsx,mdx}\" --cache"
16 | },
17 | "devDependencies": {
18 | "@ianvs/prettier-plugin-sort-imports": "^3.7.2",
19 | "prettier": "^2.8.8",
20 | "prettier-plugin-tailwindcss": "^0.1.13",
21 | "turbo": "^1.12.4"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/lib/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | # ONBOARDING_LIB
6 |
7 | [![npm version][npm-version-src]][npm-href]
8 | [![npm downloads][npm-downloads-src]][npm-href]
9 | [![Github Actions][github-actions-src]][github-actions-href]
10 |
11 | > 🪜 A tiny headless onboarding library with form validation, schema validation using Zod and persistance with unstorage.
12 |
13 | A good onboarding flow is one of the best ways to guide a new user to see the value of any new product.
14 |
15 | We built ONBOARDING_LIB to make building such onboarding flows dead simple. ONBOARDING_LIB takes care of persisting your onboarding state, handling form validation & side-effects in an intuitive way so that you can build your onboarding flow with ease.
16 |
17 | ## Demo
18 |
19 | Check out a live onboarding demo built with ONBOARDING_LIB that walks you through creating an onboarding flow [here.](https://onboarding-lib.vercel.app/)
20 |
21 | ## Features
22 |
23 | - Headless
24 | - Form validation using `react-hook-form`
25 | - Persistance using [unstorage](https://unstorage.unjs.io/)
26 |
27 | ## 💻 Example Usage
28 |
29 | ```typescript
30 | /**
31 | * Define your onboariding data schema
32 | */
33 | export const onboardingSchema = z.object({
34 | disappointment: z.enum(
35 | ["very-disappointed", "somewhat-disappointed", "not-disappointed"],
36 | { required_error: "Please fill in your disappointment level :)" }
37 | ),
38 | improvements: z.string({
39 | required_error: "Please help us improve ONBOARDING_LIB for you :)",
40 | }),
41 | })
42 |
43 | export function Demo() {
44 | // Create your Onboarding components
45 | const { Onboarding, Step } = createOnboarding({
46 | schema: onboardingSchema,
47 | })
48 |
49 | // Then simply define your onboarding steps
50 | return (
51 | {
57 | console.log("Completed")
58 | }}
59 | >
60 |
61 |
62 |
63 |
64 |
65 |
71 |
72 |
73 |
74 | )
75 | }
76 | ```
77 |
78 | ## 💻 Development
79 |
80 | - Clone this repository
81 | - Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable` (use `npm i -g corepack` for Node.js < 16.10)
82 | - Install dependencies using `pnpm install`
83 | - Run the demo website using `pnpm dev`
84 |
85 | ## License
86 |
87 | Made with ❤️ in Helsinki
88 |
89 | Published under [MIT License](./LICENSE).
90 |
91 |
92 |
93 | [npm-href]: https://npmjs.com/package/onboarding_lib
94 | [github-actions-href]: https://github.com/useflytrap/onboarding_-_lib/actions/workflows/ci.yml
95 |
96 |
97 |
98 | [npm-version-src]: https://badgen.net/npm/v/onboarding_lib?color=black
99 | [npm-downloads-src]: https://badgen.net/npm/dw/onboarding_lib?color=black
100 | [prettier-src]: https://badgen.net/badge/style/prettier/black?icon=github
101 | [github-actions-src]: https://github.com/useflytrap/onboarding_lib/actions/workflows/ci.yml/badge.svg
102 |
--------------------------------------------------------------------------------
/packages/lib/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import eslint from '@eslint/js';
3 | import tseslint from 'typescript-eslint';
4 |
5 | export default tseslint.config(
6 | {
7 | extends: [eslint.configs.recommended],
8 | files: ["{src/test}/**/*.{js,json,ts}"],
9 | },
10 | {
11 | extends: [...tseslint.configs.recommended],
12 | rules: {
13 | '@typescript-eslint/no-explicit-any': 'off'
14 | }
15 | }
16 | );
17 |
--------------------------------------------------------------------------------
/packages/lib/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "onboarding_lib",
3 | "version": "0.0.6",
4 | "description": "A tiny headless onboarding library with form validation, schema validation using Zod and persistance with unstorage.",
5 | "repository": "useflytrap/onboarding_lib",
6 | "exports": {
7 | ".": {
8 | "types": "./dist/index.d.ts",
9 | "import": "./dist/index.mjs",
10 | "require": "./dist/index.cjs"
11 | }
12 | },
13 | "main": "./dist/index.cjs",
14 | "module": "./dist/index.mjs",
15 | "types": "./dist/index.d.ts",
16 | "files": [
17 | "dist"
18 | ],
19 | "author": "Rasmus Gustafsson ",
20 | "license": "MIT",
21 | "type": "module",
22 | "keywords": [
23 | "typescript",
24 | "react",
25 | "onboarding",
26 | "form-validation",
27 | "zod",
28 | "unstorage"
29 | ],
30 | "scripts": {
31 | "build": "unbuild",
32 | "test": "pnpm test:core && pnpm test:types",
33 | "test:core": "vitest run",
34 | "test:types": "tsc --noEmit",
35 | "test:coverage": "vitest run --coverage",
36 | "lint": "pnpm eslint --fix \"{src,test}/**/*.{js,json,ts}\"",
37 | "prepublishOnly": "pnpm lint",
38 | "release": "np --no-tests"
39 | },
40 | "dependencies": {
41 | "@hookform/resolvers": "^3.3.4",
42 | "react": "^18.2.0",
43 | "react-hook-form": "^7.49.3",
44 | "unstorage": "^1.10.1",
45 | "zod": "^3.21.4"
46 | },
47 | "devDependencies": {
48 | "@types/node": "^18.16.0",
49 | "@types/react": "^18.2.45",
50 | "@types/react-dom": "18.0.6",
51 | "@typescript-eslint/eslint-plugin": "^7.0.2",
52 | "autoprefixer": "^10.4.14",
53 | "eslint": "^8.56.0",
54 | "eslint-config-next": "13.0.0",
55 | "eslint-config-prettier": "^8.8.0",
56 | "eslint-plugin-prettier": "^5.1.3",
57 | "eslint-plugin-react": "^7.32.2",
58 | "eslint-plugin-tailwindcss": "^3.11.0",
59 | "postcss": "^8.4.23",
60 | "typescript": "^5.3.3",
61 | "typescript-eslint": "^7.0.2",
62 | "unbuild": "^2.0.0",
63 | "vite": "^5.1.4",
64 | "vitest": "^1.3.1"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/packages/lib/src/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react"
2 |
3 | export function useDebounce(value: T, delay: number) {
4 | const [debouncedValue, setDebouncedValue] = useState(value)
5 |
6 | useEffect(() => {
7 | const handler = setTimeout(() => {
8 | setDebouncedValue(value)
9 | }, delay)
10 |
11 | return () => clearTimeout(handler)
12 | }, [value, delay])
13 |
14 | return debouncedValue
15 | }
16 |
--------------------------------------------------------------------------------
/packages/lib/src/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { createContext, useContext, useEffect, useMemo, useState } from "react"
3 | import { zodResolver } from "@hookform/resolvers/zod"
4 | import { FormProvider, useForm, useWatch } from "react-hook-form"
5 | import { ZodSchema, z } from "zod"
6 |
7 | import { useDebounce } from "./hooks"
8 | import {
9 | OnboardingContextValue,
10 | OnboardingProps,
11 | OnboardingStepProps,
12 | } from "./types"
13 | import {
14 | childrenWithPropsArray,
15 | createStoragePrefixFn,
16 | getComputedMarkAsCompleted,
17 | } from "./utils"
18 |
19 | const OnboardingContext = createContext>(
20 | {} as OnboardingContextValue
21 | )
22 |
23 | function Onboarding({
24 | schema,
25 | defaultValues,
26 | storage,
27 | userId,
28 | children,
29 | id,
30 | storageDebounceDelay = 500,
31 | onCompleted,
32 | onInvalid,
33 | }: OnboardingProps) {
34 | const STEPS = Array.isArray(children)
35 | ? children?.map((c) => c.props.stepId)
36 | : [(children as React.ReactElement)?.props?.stepId]
37 |
38 | if (STEPS === undefined || STEPS.length === 0 || STEPS.at(0) === undefined) {
39 | throw new Error(
40 | ` expects at least one component as a direct child.`
41 | )
42 | }
43 |
44 | const [currentStep, setCurrentStep] = useState(0)
45 | const [currentStepId, setStepId] = useState(STEPS[currentStep])
46 | const [completedStepIds, setCompletedStepIds] = useState([])
47 | const [hasLoaded, setHasLoaded] = useState(false)
48 |
49 | const form = useForm>({
50 | resolver: zodResolver(schema),
51 | defaultValues,
52 | })
53 |
54 | const withStoragePrefix = createStoragePrefixFn(id, userId)
55 |
56 | useEffect(() => {
57 | setStepId(STEPS[currentStep])
58 | }, [currentStep])
59 |
60 | useEffect(() => {
61 | if (hasLoaded) {
62 | storage.setItem(withStoragePrefix("step"), currentStep)
63 | }
64 | }, [currentStep, hasLoaded])
65 |
66 | useEffect(() => {
67 | if (hasLoaded) {
68 | storage.setItem(withStoragePrefix("completed_marks"), completedStepIds)
69 | }
70 | }, [completedStepIds, hasLoaded])
71 |
72 | useEffect(() => {
73 | async function fetchOnboardingState() {
74 | const formValues = await storage.getItem>(
75 | withStoragePrefix("form")
76 | )
77 | const currentStep = await storage.getItem(
78 | withStoragePrefix("step")
79 | )
80 | let completedMarks =
81 | (await storage.getItem(
82 | withStoragePrefix("completed_marks")
83 | )) ?? []
84 | if (formValues) {
85 | for (const [key, value] of Object.entries(formValues)) {
86 | // @ts-expect-error
87 | form.setValue(key, value)
88 | }
89 | }
90 | // Go through `markAsCompleted` functions for all steps
91 | const markAsCompletedFunctionMap = childrenWithPropsArray<
92 | OnboardingStepProps
93 | >(children)
94 | .filter((c) => c.props.markAsCompleted !== undefined)
95 | .map((c) => ({
96 | stepId: c.props.stepId,
97 | markAsCompleted: c.props.markAsCompleted!,
98 | }))
99 |
100 | for (let i = 0; i < markAsCompletedFunctionMap.length; i++) {
101 | const { stepId, markAsCompleted } = markAsCompletedFunctionMap[i]
102 | const computedMarkAsCompleted = await getComputedMarkAsCompleted(
103 | markAsCompleted,
104 | formValues
105 | )
106 |
107 | // Reconcile the `computedMarkAsCompleted` and the saved `completedMarks`
108 | if (
109 | completedMarks.includes(stepId) &&
110 | computedMarkAsCompleted === false
111 | ) {
112 | // Remove the stepId from completed marks
113 | completedMarks = completedMarks.filter(
114 | (predicateStepId) => predicateStepId !== stepId
115 | )
116 | }
117 |
118 | if (
119 | !completedMarks.includes(stepId) &&
120 | computedMarkAsCompleted === true
121 | ) {
122 | completedMarks.push(stepId)
123 | }
124 | }
125 |
126 | // Save our completed steps
127 | setCompletedStepIds(completedMarks)
128 |
129 | if (currentStep) {
130 | setCurrentStep(currentStep)
131 | }
132 |
133 | setHasLoaded(true)
134 | }
135 |
136 | fetchOnboardingState()
137 | }, [])
138 |
139 | const formValues = useWatch({
140 | control: form.control,
141 | defaultValue: defaultValues,
142 | })
143 | const debouncedFormValues = useDebounce(formValues, storageDebounceDelay)
144 |
145 | useEffect(() => {
146 | if (hasLoaded) {
147 | storage.setItem(withStoragePrefix("form"), debouncedFormValues)
148 | }
149 | }, [debouncedFormValues, hasLoaded])
150 |
151 | const previous = useMemo(() => {
152 | if (currentStep <= 0) return undefined
153 |
154 | return () => setCurrentStep((step) => step - 1)
155 | }, [currentStep])
156 |
157 | const next = useMemo(() => {
158 | if (currentStep >= STEPS.length - 1) return undefined
159 | return (skipped?: boolean) => {
160 | if (skipped === undefined) {
161 | const currentStepId = STEPS[currentStep]
162 | setCompletedStepIds((completedStepIds) => {
163 | const completedStepIdsSet = new Set(completedStepIds)
164 | completedStepIdsSet.add(currentStepId)
165 | return Array.from(completedStepIdsSet)
166 | })
167 | }
168 | setCurrentStep((step) => step + 1)
169 | }
170 | }, [currentStep])
171 |
172 | const goto = (stepId: string) => {
173 | const stepIndex = STEPS.findIndex((step) => step === stepId)
174 | if (stepIndex === -1) {
175 | throw new Error(
176 | `Go to step "${stepId}" failed. Could not find a step with that ID from steps: ${STEPS.join(
177 | ", "
178 | )}`
179 | )
180 | }
181 |
182 | setCurrentStep(stepIndex)
183 | }
184 |
185 | return (
186 |
202 |
203 |
208 |
209 |
210 | )
211 | }
212 |
213 | function OnboardingStep({
214 | stepId,
215 | skippable = true,
216 | validateFormFields,
217 | markAsCompleted,
218 | onMarkAsCompletedFailed,
219 | onStepCompleted,
220 | render,
221 | }: OnboardingStepProps) {
222 | const context = useContext(OnboardingContext)
223 |
224 | // Skip is just `next` without the validation, or `onStepCompleted`
225 | const skip = useMemo(() => {
226 | if (skippable && context.next) {
227 | return () => context.next!(true)
228 | }
229 | }, [skippable, context.next])
230 |
231 | const next = useMemo(() => {
232 | if (context.next === undefined) return undefined
233 |
234 | return async () => {
235 | if (validateFormFields) {
236 | const isValid = await context.form.trigger(
237 | validateFormFields as string[]
238 | )
239 | if (isValid === false) return
240 | }
241 |
242 | if (markAsCompleted) {
243 | const computedMarkAsCompleted = await getComputedMarkAsCompleted(
244 | markAsCompleted,
245 | context.form.getValues()
246 | )
247 | if (computedMarkAsCompleted === false) {
248 | onMarkAsCompletedFailed?.(context.form.getValues())
249 | return
250 | }
251 | }
252 |
253 | onStepCompleted?.(context.form.getValues())
254 | context.next?.()
255 | }
256 | }, [context.next])
257 |
258 | return render({
259 | ...context,
260 | stepId,
261 | skip,
262 | next,
263 | isCurrentStep: context.currentStepId === stepId,
264 | isMarkedAsCompleted: context.completedStepIds.includes(stepId),
265 | })
266 | }
267 |
268 | export function createOnboarding({
269 | schema,
270 | }: Pick, "schema">) {
271 | return {
272 | Onboarding: Onboarding,
273 | Step: OnboardingStep,
274 | }
275 | }
276 |
277 | export * from "./types"
278 |
--------------------------------------------------------------------------------
/packages/lib/src/types.ts:
--------------------------------------------------------------------------------
1 | import { ReactNode } from "react"
2 | import {
3 | DefaultValues,
4 | SubmitErrorHandler,
5 | type UseFormReturn,
6 | } from "react-hook-form"
7 | import { Storage } from "unstorage"
8 | import { ZodSchema, z } from "zod"
9 |
10 | export type OnboardingProps = {
11 | /**
12 | * Identifier for the onboarding. This will be used to separate the data from possible other onboarding flows.
13 | */
14 | id: string
15 | storage: Storage
16 | schema: T
17 | defaultValues?: DefaultValues>
18 | children: ReactNode
19 | userId: string
20 | onCompleted?: (data: z.infer) => void
21 | onInvalid?: SubmitErrorHandler
22 | /**
23 | * Amount of milliseconds to debounce before saving the values to the provided `Storage`.
24 | * @default 500 ms
25 | */
26 | storageDebounceDelay?: number
27 | }
28 |
29 | export type OnboardingContextValue = Omit<
30 | OnboardingProps,
31 | "children" | "defaultValues"
32 | > & {
33 | previous?: () => void
34 | next?: (skipped?: boolean) => void
35 | skip?: () => void
36 | goto: (stepId: string) => void
37 | form: UseFormReturn, any, z.infer>
38 | currentStepId: string
39 | completedStepIds: string[]
40 | }
41 |
42 | export type OnboardingStepRenderProps =
43 | OnboardingContextValue & {
44 | stepId: string
45 | isCurrentStep: boolean
46 | isMarkedAsCompleted: boolean
47 | }
48 |
49 | export type OnboardingStepProps = {
50 | /**
51 | * The ID of this step. Should be unique.
52 | */
53 | stepId: string
54 | /**
55 | * The `markAsCompleted` value can be used to simply mark the step as completed, but can also
56 | * be used to for instance run side-effects, and proceed only when those have succeeded. If you return
57 | * `false`, the `onMarkAsCompletedFailed` function will be called.
58 | */
59 | markAsCompleted?:
60 | | ((data: z.infer) => Promise)
61 | | ((data: z.infer) => boolean)
62 | | boolean
63 | /**
64 | * This function will get called, when the user is trying to proceed to the next step (by calling the
65 | * `next` function), but the `markAsCompleted` returns false for this step.
66 | */
67 | onMarkAsCompletedFailed?: (data: z.infer) => void
68 | /**
69 | * Whether this step can be skipped. If set to `false`, the value of the `skip` prop will be `undefined`.
70 | * @default true
71 | */
72 | skippable?: boolean
73 | /**
74 | * Whether this step should be marked as disabled. This value doesn't actually do anything at the moment.
75 | */
76 | disabled?: boolean
77 | /**
78 | * Enable the validation of the form fields provided, when `next` is called. If no form fields are provided, validation won't happen until the final submit.
79 | */
80 | validateFormFields?: (keyof z.infer)[]
81 | /**
82 | * Run code when this step has completed. Useful for tracking onboarding conversion rates using product analytics tools.
83 | */
84 | onStepCompleted?: (data: z.infer) => void | Promise
85 | /**
86 | * The function that will be called to render your onboarding step component.
87 | * @returns A React component
88 | */
89 | render: (values: OnboardingStepRenderProps) => React.JSX.Element
90 | }
91 |
--------------------------------------------------------------------------------
/packages/lib/src/utils.ts:
--------------------------------------------------------------------------------
1 | import type { ReactElement, ReactNode } from "react"
2 | import { z, type ZodSchema } from "zod"
3 |
4 | import { OnboardingStepProps } from "./types"
5 |
6 | export function childrenWithPropsArray>(
7 | children: ReactNode
8 | ) {
9 | return Array.isArray(children)
10 | ? (children as ReactElement[])
11 | : [children as ReactElement]
12 | }
13 |
14 | export async function getComputedMarkAsCompleted(
15 | markAsCompleted: Exclude<
16 | OnboardingStepProps["markAsCompleted"],
17 | undefined
18 | >,
19 | formValues: z.infer
20 | ) {
21 | let computedMarkAsCompleted = false
22 | if (markAsCompleted === true || markAsCompleted === false) {
23 | computedMarkAsCompleted = markAsCompleted
24 | } else {
25 | try {
26 | computedMarkAsCompleted = await markAsCompleted(formValues)
27 | } catch {}
28 | }
29 | return computedMarkAsCompleted
30 | }
31 |
32 | export function createStoragePrefixFn(onboardingId: string, userId: string) {
33 | return (storageKey: string) => `${onboardingId}:${userId}:${storageKey}`
34 | }
35 |
--------------------------------------------------------------------------------
/packages/lib/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "module": "esnext",
5 | "moduleResolution": "node",
6 | "esModuleInterop": true,
7 | "strict": true,
8 | "skipLibCheck": true,
9 | "jsx": "react"
10 | },
11 | "exclude": ["./website"]
12 | }
13 |
--------------------------------------------------------------------------------
/packages/lib/vite.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { defineConfig } from "vite"
4 |
5 | export default defineConfig({
6 | test: {
7 | coverage: {
8 | // @ts-ignore
9 | "100": true,
10 | include: ["src"],
11 | reporter: ["text", "json", "html"],
12 | },
13 | },
14 | })
15 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'packages/*'
3 | - 'apps/*'
--------------------------------------------------------------------------------
/prettier.config.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('prettier').Config} */
2 | module.exports = {
3 | endOfLine: "lf",
4 | semi: false,
5 | singleQuote: false,
6 | tabWidth: 2,
7 | trailingComma: "es5",
8 | importOrder: [
9 | "^(react/(.*)$)|^(react$)",
10 | "^(next/(.*)$)|^(next$)",
11 | "",
12 | "",
13 | "^types$",
14 | "^@/env(.*)$",
15 | "^@/types/(.*)$",
16 | "^@/config/(.*)$",
17 | "^@/lib/(.*)$",
18 | "^@/hooks/(.*)$",
19 | "^@/components/ui/(.*)$",
20 | "^@/components/(.*)$",
21 | "^@/styles/(.*)$",
22 | "^@/app/(.*)$",
23 | "",
24 | "^[./]",
25 | ],
26 | importOrderSeparation: false,
27 | importOrderSortSpecifiers: true,
28 | importOrderBuiltinModulesToTop: true,
29 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
30 | importOrderMergeDuplicateImports: true,
31 | importOrderCombineTypeAndValueImports: true,
32 | plugins: ["@ianvs/prettier-plugin-sort-imports"],
33 | }
34 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "pipeline": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
8 | },
9 | "dev": {
10 | "dependsOn": ["^build"],
11 | "cache": false,
12 | "persistent": true
13 | },
14 | "lint": {
15 | "cache": false
16 | },
17 | "release": {
18 | "dependsOn": ["lint", "test", "build"]
19 | },
20 | "test": {
21 | "dependsOn": ["build"],
22 | "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"]
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------