← back to Open Seo

src/client/features/onboarding/PostSignupOnboarding.tsx

301 lines

import { ArrowRight, Check } from "lucide-react";
import type { ReactNode } from "react";
import { Fragment } from "react";
import {
  CLIENT_WEBSITE_COUNT_OPTIONS,
  CLIENT_WORK_FOR,
  INTEREST_OPTIONS,
  ONBOARDING_LAST_STEP,
  type OnboardingAnswers,
  SOURCE_OPTIONS,
  SOURCE_OPTIONS_HIDDEN_ON_MOBILE,
  WORK_FOR_OPTIONS,
} from "@/client/features/onboarding/onboardingModel";
import { SearchConsoleOnboardingStep } from "@/client/features/onboarding/SearchConsoleOnboardingStep";

type PostSignupOnboardingProps = {
  firstName: string;
  title?: string;
  helperText?: string;
  step: number;
  answers: OnboardingAnswers;
  onAnswersChange: (answers: OnboardingAnswers) => void;
  onNext: () => void;
  onBack: () => void;
  onSkip: () => void;
  onFinish: () => void;
  isSaving: boolean;
  accountMenu: ReactNode;
};

export function PostSignupOnboarding({
  firstName,
  title,
  helperText,
  step,
  answers,
  onAnswersChange,
  onNext,
  onBack,
  onSkip,
  onFinish,
  isSaving,
  accountMenu,
}: PostSignupOnboardingProps) {
  const canContinue =
    step === 0
      ? answers.selectedInterests.length > 0
      : step === 1
        ? Boolean(answers.workFor)
        : step === 2
          ? Boolean(answers.source)
          : true;

  const updateAnswers = (patch: Partial<OnboardingAnswers>) =>
    onAnswersChange({ ...answers, ...patch });

  return (
    <div className="w-full max-w-md space-y-6">
      {accountMenu}

      <div className="text-center space-y-3">
        <img
          src="/transparent-logo.png"
          alt="OpenSEO"
          className="mx-auto size-10 rounded-lg"
        />
        <p className="text-xs font-medium uppercase tracking-wide text-base-content/50">
          Step {step + 1} of {ONBOARDING_LAST_STEP + 1}
        </p>
        <h1 className="text-xl font-semibold">
          {title ??
            (firstName
              ? `Welcome to OpenSEO, ${firstName}!`
              : "Welcome to OpenSEO!")}
        </h1>
        <p className="text-sm text-base-content/60">
          {helperText ?? "A few quick answers to set things up."}
        </p>
      </div>

      <div className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm">
        {step === 0 ? (
          <OnboardingChoiceGroup
            title="What tasks matter to you most?"
            description="Pick up to 3."
            maxSelections={3}
            options={[...INTEREST_OPTIONS]}
            selectedValues={answers.selectedInterests}
            onToggle={(value) => {
              updateAnswers({
                selectedInterests: answers.selectedInterests.includes(value)
                  ? answers.selectedInterests.filter((item) => item !== value)
                  : [...answers.selectedInterests, value],
              });
            }}
            otherValue={answers.interestOther}
            onOtherChange={(interestOther) => updateAnswers({ interestOther })}
            multiple
          />
        ) : step === 1 ? (
          <OnboardingChoiceGroup
            title="Who are you doing SEO for?"
            options={[...WORK_FOR_OPTIONS]}
            selectedValues={answers.workFor ? [answers.workFor] : []}
            onToggle={(workFor) => updateAnswers({ workFor })}
            otherValue={answers.workForOther}
            onOtherChange={(workForOther) => updateAnswers({ workForOther })}
            followUp={{
              showForValue: CLIENT_WORK_FOR,
              label: "About how many client sites do you work on?",
              options: [...CLIENT_WEBSITE_COUNT_OPTIONS],
              value: answers.clientWebsiteCount,
              onChange: (clientWebsiteCount) =>
                updateAnswers({ clientWebsiteCount }),
            }}
          />
        ) : step === 2 ? (
          <OnboardingChoiceGroup
            title="How did you find OpenSEO?"
            options={[...SOURCE_OPTIONS]}
            selectedValues={answers.source ? [answers.source] : []}
            onToggle={(source) => updateAnswers({ source })}
            otherValue={answers.sourceOther}
            onOtherChange={(sourceOther) => updateAnswers({ sourceOther })}
            hiddenOnMobile={[...SOURCE_OPTIONS_HIDDEN_ON_MOBILE]}
          />
        ) : (
          <SearchConsoleOnboardingStep />
        )}

        <div className="mt-5 flex items-center justify-between gap-3">
          <button
            type="button"
            className="btn btn-ghost"
            disabled={step === 0 || isSaving}
            onClick={onBack}
          >
            Back
          </button>
          {step < ONBOARDING_LAST_STEP ? (
            <div className="flex items-center gap-2">
              <button
                type="button"
                className="btn btn-ghost btn-sm text-base-content/55"
                disabled={isSaving}
                onClick={onSkip}
              >
                Skip
              </button>
              <button
                type="button"
                className="btn btn-primary"
                disabled={!canContinue || isSaving}
                onClick={onNext}
              >
                Continue
                <ArrowRight className="size-4" />
              </button>
            </div>
          ) : (
            <button
              type="button"
              className="btn btn-primary"
              disabled={isSaving}
              onClick={onFinish}
            >
              Finish
              <ArrowRight className="size-4" />
            </button>
          )}
        </div>
      </div>
    </div>
  );
}

function OnboardingChoiceGroup({
  title,
  description,
  options,
  selectedValues,
  onToggle,
  otherValue,
  onOtherChange,
  multiple = false,
  maxSelections,
  followUp,
  hiddenOnMobile,
}: {
  title: string;
  description?: string;
  options: string[];
  selectedValues: string[];
  onToggle: (value: string) => void;
  otherValue: string;
  onOtherChange: (value: string) => void;
  multiple?: boolean;
  maxSelections?: number;
  hiddenOnMobile?: string[];
  followUp?: {
    showForValue: string;
    label: string;
    options: string[];
    value: string;
    onChange: (value: string) => void;
  };
}) {
  const isOtherSelected = selectedValues.includes("Other");
  const showFollowUp =
    followUp !== undefined && selectedValues.includes(followUp.showForValue);
  const atLimit =
    maxSelections !== undefined && selectedValues.length >= maxSelections;

  return (
    <div className="space-y-4">
      <div>
        <h2 className="text-lg font-semibold">{title}</h2>
        {description ? (
          <p className="mt-1 text-sm text-base-content/60">{description}</p>
        ) : null}
      </div>

      <div className="grid gap-2">
        {options.map((option) => {
          const selected = selectedValues.includes(option);
          const disabled = atLimit && !selected;
          const showFollowUpHere =
            showFollowUp && followUp?.showForValue === option;
          // Selected options stay visible so a restored answer never vanishes.
          const mobileHidden = hiddenOnMobile?.includes(option) && !selected;

          return (
            <Fragment key={option}>
              <button
                type="button"
                className={`${mobileHidden ? "hidden sm:flex" : "flex"} min-h-11 items-center justify-between rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
                  selected
                    ? "border-base-content bg-base-200 text-base-content"
                    : disabled
                      ? "border-base-300 text-base-content/35 cursor-not-allowed"
                      : "border-base-300 text-base-content/75 hover:border-base-content/40 hover:bg-base-200/60"
                }`}
                aria-pressed={selected}
                disabled={disabled}
                onClick={() => onToggle(option)}
              >
                <span>{option}</span>
                {selected ? <Check className="size-4 shrink-0" /> : null}
              </button>

              {showFollowUpHere && followUp ? (
                <div className="rounded-lg border border-base-300 bg-base-200/40 px-3 py-2.5">
                  <p className="text-sm text-base-content/70">
                    {followUp.label}
                  </p>
                  <div className="mt-2 flex flex-wrap gap-2">
                    {followUp.options.map((followUpOption) => {
                      const followUpSelected =
                        followUp.value === followUpOption;

                      return (
                        <button
                          key={followUpOption}
                          type="button"
                          className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
                            followUpSelected
                              ? "border-base-content bg-base-200 text-base-content"
                              : "border-base-300 text-base-content/75 hover:border-base-content/40 hover:bg-base-200/60"
                          }`}
                          aria-pressed={followUpSelected}
                          onClick={() =>
                            followUp.onChange(
                              followUpSelected ? "" : followUpOption,
                            )
                          }
                        >
                          {followUpOption}
                        </button>
                      );
                    })}
                  </div>
                </div>
              ) : null}
            </Fragment>
          );
        })}
      </div>

      {isOtherSelected ? (
        <input
          type="text"
          className="input input-bordered w-full"
          placeholder={multiple ? "Tell us what else..." : "Tell us more..."}
          value={otherValue}
          onChange={(event) => onOtherChange(event.target.value)}
        />
      ) : null}
    </div>
  );
}