Skip to main content

Command Palette

Search for a command to run...

Tailwind css & shadcn step form based register page

Published
6 min read
import { useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Link, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { format } from "date-fns"
import {
  CalendarIcon,
  Loader2,
  Eye,
  EyeOff,
  ShieldCheck,
  HeartPulse,
  ArrowLeft,
  ArrowRight,
} from "lucide-react"
import { cn, NativeSelect } from "@hms/shared"
import { Button } from "@hms/shared/components/ui/button"
import { Calendar } from "@hms/shared/components/ui/calendar"
import { Input } from "@hms/shared/components/ui/input"
import { Label } from "@hms/shared/components/ui/label"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@hms/shared/components/ui/popover"

const STEPS = [
  { title: "Personal Info", description: "Tell us about yourself" },
  { title: "Contact Details", description: "How can we reach you?" },
  { title: "Security", description: "Set up your password and preferences" },
] as const

const stepOneSchema = z.object({
  fullName: z.string().min(2, "Full name is required"),
  gender: z.string().min(1, "Please select a gender"),
  dateOfBirth: z.string().min(1, "Date of birth is required"),
})

const stepTwoSchema = z.object({
  phone: z.string().min(10, "Enter a valid phone number"),
  email: z.string().email("Enter a valid email").optional().or(z.literal("")),
})

const registerSchema = z
  .object({
    fullName: z.string().min(2, "Full name is required"),
    gender: z.string().min(1, "Please select a gender"),
    dateOfBirth: z.string().min(1, "Date of birth is required"),
    phone: z.string().min(10, "Enter a valid phone number"),
    email: z.string().email("Enter a valid email").optional().or(z.literal("")),
    password: z.string().min(6, "Password must be at least 6 characters"),
    confirmPassword: z.string(),
    bloodGroup: z.string().optional(),
    city: z.string().optional(),
  })
  .refine((d) => d.password === d.confirmPassword, {
    message: "Passwords do not match",
    path: ["confirmPassword"],
  })

type RegisterFormValues = z.infer<typeof registerSchema>

export default function SplitRegister() {
  const navigate = useNavigate()
  const [step, setStep] = useState(0)
  const [dobOpen, setDobOpen] = useState(false)
  const [showPassword, setShowPassword] = useState(false)
  const [showConfirmPassword, setShowConfirmPassword] = useState(false)

  const {
    register,
    handleSubmit,
    setValue,
    watch,
    trigger,
    formState: { errors, isSubmitting },
  } = useForm<RegisterFormValues>({
    resolver: zodResolver(registerSchema),
    mode: "onTouched",
  })

  const dobValue = watch("dateOfBirth")

  async function onSubmit(data: RegisterFormValues) {
    await new Promise((r) => setTimeout(r, 1000))
    localStorage.setItem("access_token", "mock-token")
    localStorage.setItem(
      "user",
      JSON.stringify({
        name: data.fullName,
        email: data.email || data.phone,
        role: "PATIENT",
      })
    )
    toast.success("Account created successfully!")
    navigate("/dashboard")
  }

  async function handleNext() {
    if (step === 0) {
      const fields = Object.keys(stepOneSchema.shape) as (keyof z.infer<typeof stepOneSchema>)[]
      const valid = await trigger(fields)
      if (!valid) return
    } else if (step === 1) {
      const fields = Object.keys(stepTwoSchema.shape) as (keyof z.infer<typeof stepTwoSchema>)[]
      const valid = await trigger(fields)
      if (!valid) return
    }
    setStep((s) => Math.min(s + 1, STEPS.length - 1))
  }

  function handleBack() {
    setStep((s) => Math.max(s - 1, 0))
  }

  const inputClass =
    "h-11 rounded-lg border-zinc-300 bg-white text-zinc-900 placeholder:text-zinc-400 focus-visible:border-zinc-900 focus-visible:ring-zinc-900/20"
  const selectClass =
    "h-11 rounded-lg border-zinc-300 bg-white text-zinc-900 focus-visible:border-zinc-900 focus-visible:ring-zinc-900/20"

  return (
    <div className="flex min-h-svh">
      {/* Left Panel - Dark Branding */}
      <div className="hidden lg:flex lg:w-1/2 bg-zinc-950 text-white flex-col justify-between p-10">
        <div>
          <h1 className="text-3xl font-bold tracking-tight">Get Started</h1>
          <p className="mt-2 text-zinc-400">
            Create your account to manage your health journey
          </p>
        </div>

        <div className="space-y-6">
          <div className="flex items-start gap-4">
            <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-zinc-800">
              <ShieldCheck className="h-5 w-5 text-zinc-300" />
            </div>
            <div>
              <h3 className="font-semibold">Private & Secure</h3>
              <p className="text-sm text-zinc-400">
                Your health data is encrypted and protected at all times
              </p>
            </div>
          </div>
          <div className="flex items-start gap-4">
            <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-zinc-800">
              <HeartPulse className="h-5 w-5 text-zinc-300" />
            </div>
            <div>
              <h3 className="font-semibold">Complete Health Management</h3>
              <p className="text-sm text-zinc-400">
                Appointments, records, prescriptions — all in one place
              </p>
            </div>
          </div>
        </div>

        <p className="text-sm text-zinc-500">
          &copy; {new Date().getFullYear()} Desk. All rights reserved.
        </p>
      </div>

      {/* Right Panel - Step Form */}
      <div className="flex w-full lg:w-1/2 items-center justify-center bg-white p-6 sm:p-10">
        <div className="w-full max-w-md space-y-6">
          {/* Header */}
          <div>
            <h2 className="text-2xl font-bold text-zinc-900">{STEPS[step].title}</h2>
            <p className="mt-1 text-sm text-zinc-500">{STEPS[step].description}</p>
          </div>

          {/* Step Indicator */}
          <div className="flex items-center gap-2">
            {STEPS.map((_, i) => (
              <div
                key={i}
                className={cn(
                  "h-1.5 flex-1 rounded-full transition-colors",
                  i <= step ? "bg-zinc-900" : "bg-zinc-200"
                )}
              />
            ))}
          </div>
          <p className="text-xs text-zinc-400">
            Step {step + 1} of {STEPS.length}
          </p>

          <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
            {/* Step 1: Personal Info */}
            {step === 0 && (
              <>
                <div className="space-y-2">
                  <Label htmlFor="fullName" className="text-zinc-700">Full Name</Label>
                  <Input
                    id="fullName"
                    placeholder="John Doe"
                    className={inputClass}
                    aria-invalid={!!errors.fullName}
                    {...register("fullName")}
                  />
                  {errors.fullName && (
                    <p className="text-sm text-destructive">{errors.fullName.message}</p>
                  )}
                </div>

                <div className="space-y-2">
                  <Label htmlFor="gender" className="text-zinc-700">Gender</Label>
                  <NativeSelect
                    id="gender"
                    className={selectClass}
                    aria-invalid={!!errors.gender}
                    {...register("gender")}
                  >
                    <option value="">Select gender</option>
                    <option value="male">Male</option>
                    <option value="female">Female</option>
                    <option value="other">Other</option>
                  </NativeSelect>
                  {errors.gender && (
                    <p className="text-sm text-destructive">{errors.gender.message}</p>
                  )}
                </div>

                <div className="space-y-2">
                  <Label className="text-zinc-700">Date of Birth</Label>
                  <Popover open={dobOpen} onOpenChange={setDobOpen}>
                    <PopoverTrigger
                      className={cn(
                        "flex h-11 w-full items-center justify-start gap-2 rounded-lg border border-zinc-300 bg-white px-3 text-sm",
                        "focus-visible:border-zinc-900 focus-visible:ring-3 focus-visible:ring-zinc-900/20 focus-visible:outline-none",
                        !dobValue && "text-zinc-400"
                      )}
                    >
                      <CalendarIcon className="size-4" />
                      {dobValue ? format(new Date(dobValue), "PPP") : "Pick a date"}
                    </PopoverTrigger>
                    <PopoverContent align="start" className="w-auto p-0">
                      <Calendar
                        mode="single"
                        selected={dobValue ? new Date(dobValue) : undefined}
                        onSelect={(date) => {
                          setValue(
                            "dateOfBirth",
                            date ? format(date, "yyyy-MM-dd") : "",
                            { shouldValidate: true }
                          )
                          setDobOpen(false)
                        }}
                        captionLayout="dropdown"
                        defaultMonth={new Date(1990, 0)}
                        disabled={{ after: new Date() }}
                      />
                    </PopoverContent>
                  </Popover>
                  {errors.dateOfBirth && (
                    <p className="text-sm text-destructive">{errors.dateOfBirth.message}</p>
                  )}
                </div>
              </>
            )}

            {/* Step 2: Contact Details */}
            {step === 1 && (
              <>
                <div className="space-y-2">
                  <Label htmlFor="phone" className="text-zinc-700">Mobile Number</Label>
                  <Input
                    id="phone"
                    type="tel"
                    placeholder="+91 9876543210"
                    className={inputClass}
                    aria-invalid={!!errors.phone}
                    {...register("phone")}
                  />
                  {errors.phone && (
                    <p className="text-sm text-destructive">{errors.phone.message}</p>
                  )}
                </div>

                <div className="space-y-2">
                  <Label htmlFor="email" className="text-zinc-700">Email (optional)</Label>
                  <Input
                    id="email"
                    type="email"
                    placeholder="patient@example.com"
                    autoComplete="email"
                    className={inputClass}
                    aria-invalid={!!errors.email}
                    {...register("email")}
                  />
                  {errors.email && (
                    <p className="text-sm text-destructive">{errors.email.message}</p>
                  )}
                </div>
              </>
            )}

            {/* Step 3: Security & Preferences */}
            {step === 2 && (
              <>
                <div className="space-y-2">
                  <Label htmlFor="password" className="text-zinc-700">Password</Label>
                  <div className="relative">
                    <Input
                      id="password"
                      type={showPassword ? "text" : "password"}
                      placeholder="••••••••"
                      autoComplete="new-password"
                      className={cn(inputClass, "pr-10")}
                      aria-invalid={!!errors.password}
                      {...register("password")}
                    />
                    <button
                      type="button"
                      onClick={() => setShowPassword(!showPassword)}
                      className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"
                      tabIndex={-1}
                      aria-label={showPassword ? "Hide password" : "Show password"}
                    >
                      {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
                    </button>
                  </div>
                  {errors.password && (
                    <p className="text-sm text-destructive">{errors.password.message}</p>
                  )}
                </div>

                <div className="space-y-2">
                  <Label htmlFor="confirmPassword" className="text-zinc-700">Confirm Password</Label>
                  <div className="relative">
                    <Input
                      id="confirmPassword"
                      type={showConfirmPassword ? "text" : "password"}
                      placeholder="••••••••"
                      autoComplete="new-password"
                      className={cn(inputClass, "pr-10")}
                      aria-invalid={!!errors.confirmPassword}
                      {...register("confirmPassword")}
                    />
                    <button
                      type="button"
                      onClick={() => setShowConfirmPassword(!showConfirmPassword)}
                      className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600"
                      tabIndex={-1}
                      aria-label={showConfirmPassword ? "Hide password" : "Show password"}
                    >
                      {showConfirmPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
                    </button>
                  </div>
                  {errors.confirmPassword && (
                    <p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
                  )}
                </div>

                <div className="grid gap-4 sm:grid-cols-2">
                  <div className="space-y-2">
                    <Label htmlFor="bloodGroup" className="text-zinc-700">Blood Group</Label>
                    <NativeSelect id="bloodGroup" className={selectClass} {...register("bloodGroup")}>
                      <option value="">Select (optional)</option>
                      <option value="A+">A+</option>
                      <option value="A-">A-</option>
                      <option value="B+">B+</option>
                      <option value="B-">B-</option>
                      <option value="AB+">AB+</option>
                      <option value="AB-">AB-</option>
                      <option value="O+">O+</option>
                      <option value="O-">O-</option>
                    </NativeSelect>
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="city" className="text-zinc-700">City</Label>
                    <Input
                      id="city"
                      placeholder="Chennai"
                      className={inputClass}
                      {...register("city")}
                    />
                  </div>
                </div>
              </>
            )}

            {/* Navigation Buttons */}
            <div className="flex gap-3 pt-2">
              {step > 0 && (
                <Button
                  type="button"
                  variant="outline"
                  size="lg"
                  className="h-11 flex-1 rounded-lg border-zinc-300 text-zinc-700 hover:bg-zinc-50"
                  onClick={handleBack}
                >
                  <ArrowLeft className="h-4 w-4" />
                  Back
                </Button>
              )}

              {step < STEPS.length - 1 ? (
                <Button
                  type="button"
                  size="lg"
                  className="h-11 flex-1 rounded-lg bg-zinc-900 text-white hover:bg-zinc-800"
                  onClick={handleNext}
                >
                  Continue
                  <ArrowRight className="h-4 w-4" />
                </Button>
              ) : (
                <Button
                  type="submit"
                  size="lg"
                  className="h-11 flex-1 rounded-lg bg-zinc-900 text-white hover:bg-zinc-800"
                  disabled={isSubmitting}
                >
                  {isSubmitting && <Loader2 className="animate-spin" />}
                  {isSubmitting ? "Creating account..." : "Create Account"}
                </Button>
              )}
            </div>
          </form>

          <p className="text-sm text-zinc-500 text-center">
            Already have an account?{" "}
            <Link to="/auth/login" className="text-zinc-900 font-medium hover:underline">
              Sign in
            </Link>
          </p>
        </div>
      </div>
    </div>
  )
}

Example output