بلاک‌ها · موبایل

ورودِ تمام‌صفحهٔ اپ: شماره و کدِ ۶ رقمی در دو قدم، شمارشِ معکوسِ ارسالِ دوباره، خطای کد و دکمهٔ اصلیِ چسبیده به پایین بالای ناحیهٔ امن.

۱. نصب کامپوننت‌ها

این بلاک از ۸ آیتم دیگ استفاده می‌کند: app-barbuttoncontrol-sizeiconsinput-otplinkpersiantext-field

پکیج‌منیجر پیش‌فرض Node.jsنصب سریع با لینک سخت و دیسک مشترکYarn نسخهٔ ۲ به بالا (Berry)رانتایم و پکیج‌منیجر Bun
npx digdesign@latest add https://docs.digdesign.ir/r/app-bar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/link.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/text-field.json
pnpm dlx digdesign@latest add https://docs.digdesign.ir/r/app-bar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/link.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/text-field.json
yarn dlx digdesign@latest add https://docs.digdesign.ir/r/app-bar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/link.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/text-field.json
bunx --bun digdesign@latest add https://docs.digdesign.ir/r/app-bar.json https://docs.digdesign.ir/r/button.json https://docs.digdesign.ir/r/control-size.json https://docs.digdesign.ir/r/icons.json https://docs.digdesign.ir/r/input-otp.json https://docs.digdesign.ir/r/link.json https://docs.digdesign.ir/r/persian.json https://docs.digdesign.ir/r/text-field.json

۲. کد صفحه

کد را در app/<route>/page.tsx بگذارید و متن‌ها، داده و هندلرها را با محصول خودتان عوض کنید.

mobile-login.tsx
"use client"

import * as React from "react"

import { ChevronRightIcon, SparklesIcon } from "@/components/icons"
import { AppBar, AppBarLeading } from "@/components/ui/app-bar"
import { Button } from "@/components/ui/button"
import { ControlSizeProvider } from "@/components/ui/control-size"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Link } from "@/components/ui/link"
import { TextField } from "@/components/ui/text-field"
import { isValidMobile, toFaDigits } from "@/lib/persian"

const RESEND_SECONDS = 60

/**
 * ورودِ اپ با کدِ پیامکی، تمام‌صفحه: نشانِ برند و تیتر بالا، فیلدِ موبایل یا کدِ ۶ رقمی وسط،
 * و دکمهٔ اصلی چسبیده به پایین (بالای ناحیهٔ امن) تا با کیبورد هم در دسترس بماند.
 */
export default function MobileLoginBlock() {
  const [step, setStep] = React.useState<"mobile" | "code">("mobile")
  const [mobile, setMobile] = React.useState("")
  const [code, setCode] = React.useState("")
  const [seconds, setSeconds] = React.useState(RESEND_SECONDS)
  const [loading, setLoading] = React.useState(false)
  const [invalid, setInvalid] = React.useState(false)

  React.useEffect(() => {
    if (step !== "code" || seconds === 0) return
    const timer = setTimeout(() => setSeconds((value) => value - 1), 1000)
    return () => clearTimeout(timer)
  }, [step, seconds])

  async function submit(event: React.FormEvent) {
    event.preventDefault()
    setLoading(true)
    await new Promise((resolve) => setTimeout(resolve, 700))
    setLoading(false)
    if (step === "mobile") {
      setSeconds(RESEND_SECONDS)
      setStep("code")
    } else {
      // جای بررسی واقعی کد؛ این نمونه فقط ۱۲۳۴۵۶ را می‌پذیرد.
      setInvalid(code !== "123456")
    }
  }

  const ready = step === "mobile" ? isValidMobile(mobile) : code.length === 6

  return (
    <ControlSizeProvider size="xl" asChild>
      <form onSubmit={submit} className="mx-auto flex min-h-svh w-full max-w-md flex-col bg-background">
        <AppBar>
          <AppBarLeading>
            {step === "code" && (
              <Button type="button" variant="ghost" size="icon" aria-label="ویرایشِ شماره" onClick={() => setStep("mobile")}>
                <ChevronRightIcon className="ltr:rotate-180" />
              </Button>
            )}
          </AppBarLeading>
        </AppBar>

        <main className="flex flex-1 flex-col gap-8 px-6 pt-6">
          <header className="flex flex-col gap-4">
            <span aria-hidden className="flex size-14 items-center justify-center rounded-xl bg-[var(--dig-violet-700)] text-[var(--dig-white)] [&>svg]:size-7">
              <SparklesIcon />
            </span>
            <h1 className="text-3xl font-black">{step === "mobile" ? "خوش آمدی" : "کد را وارد کن"}</h1>
            <p className="leading-7 text-muted-foreground">
              {step === "mobile" ? (
                "شماره‌ات را بنویس؛ کدِ ورود پیامک می‌شود."
              ) : (
                <>
                  کدِ ۶ رقمی به <bdi dir="ltr" className="font-medium text-foreground">{toFaDigits(mobile)}</bdi> فرستاده شد.
                </>
              )}
            </p>
          </header>

          {step === "mobile" ? (
            <TextField label="شماره موبایل" name="mobile" type="tel" inputMode="numeric" dir="ltr" maxLength={11} autoComplete="tel" placeholder="09121234567" value={mobile} onValueChange={setMobile} autoFocus />
          ) : (
            <div className="flex flex-col gap-4">
              <InputOTP
                aria-label="کدِ تأیید"
                value={code}
                onValueChange={(value) => {
                  setCode(value)
                  setInvalid(false)
                }}
                aria-invalid={invalid || undefined}
              >
                <InputOTPGroup>
                  {[0, 1, 2, 3, 4, 5].map((index) => (
                    <InputOTPSlot key={index} index={index} />
                  ))}
                </InputOTPGroup>
              </InputOTP>
              {invalid && (
                <p role="alert" className="text-sm text-destructive">
                  کد درست نیست؛ دوباره نگاه کن.
                </p>
              )}
              {seconds > 0 ? (
                <span className="text-sm text-muted-foreground">ارسالِ دوباره تا {toFaDigits(`${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`)}</span>
              ) : (
                <Button type="button" variant="ghost" className="self-start" onClick={() => setSeconds(RESEND_SECONDS)}>
                  ارسالِ دوبارهٔ کد
                </Button>
              )}
            </div>
          )}
        </main>

        <footer className="sticky bottom-0 flex flex-col gap-4 bg-background p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
          <Button type="submit" fullWidth loading={loading} disabled={!ready}>
            {step === "mobile" ? "دریافتِ کد" : "ورود"}
          </Button>
          <p className="text-center text-xs leading-6 text-muted-foreground">
            ورود یعنی پذیرفتنِ <Link href="#" size="sm">قوانین</Link> و <Link href="#" size="sm">حریمِ خصوصی</Link>.
          </p>
        </footer>
      </form>
    </ControlSizeProvider>
  )
}