You are viewing docs for an older version
This page documents v0.2. The latest version is v0.3.
Validation
Check phone numbers, emails, card numbers and password strength.
Validators for forms: Uzbek phone numbers, emails, Uzcard / Humo / Visa / Mastercard cards and
password strength. None of them throw — null or bad input returns false (or an empty result),
so you can call them straight from onChange.
import {
parsePhone,
isValidPhone,
isValidEmail,
isValidCardNumber,
detectCardBrand,
luhnCheck,
passwordStrength,
} from '@akhatmirzo/core';Phone and email
digits: 901234567
Email manzil noto'g'ri
'use client';
import { isValidEmail, parsePhone } from '@akhatmirzo/core';
import { Badge, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function CorePhoneValidate() {
const [phone, setPhone] = useState('+998 90 123-45-67');
const [email, setEmail] = useState('sarvar@mail');
const parsed = parsePhone(phone);
const code = parsed.digits.slice(0, 2);
const phoneError =
code.length === 2 && !parsed.operator ? `"${code}" — bunday operator kodi yo'q` : undefined;
const emailValid = isValidEmail(email);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<Field label="Telefon" error={phoneError} description={`digits: ${parsed.digits || '—'}`}>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} inputMode="tel" />
</Field>
<div className="flex flex-wrap gap-2">
<Badge color={parsed.valid ? 'success' : 'neutral'} dot>
{parsed.valid ? "To'g'ri raqam" : "To'liq emas"}
</Badge>
{parsed.operator && <Badge color="info">Operator: {parsed.operator}</Badge>}
</div>
<Field
label="Email"
error={email.trim() && !emailValid ? "Email manzil noto'g'ri" : undefined}
>
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field>
</div>
);
}
parsePhone
function parsePhone(input: string | number | null | undefined): ParsedPhone;
interface ParsedPhone {
digits: string; // digits without +998; exactly 9 for a valid number
operator: UzOperatorCode | null; // '90', '33' ... or null
valid: boolean; // 9 digits and a known operator code
}Parses a number: spaces, dashes and parentheses are dropped and +998 is removed. Send digits
to your server — it always has the same shape.
parsePhone('+998 90 123-45-67'); // { digits: '901234567', operator: '90', valid: true }
parsePhone('998901234567'); // { digits: '901234567', operator: '90', valid: true }
parsePhone('(90) 123'); // { digits: '90123', operator: '90', valid: false }
parsePhone('121234567'); // { digits: '121234567', operator: null, valid: false }As in formatPhone, the 998 prefix is only removed when it comes with a + or when there are
more than 9 digits.
isValidPhone
function isValidPhone(input: string | number | null | undefined): boolean;Shorthand for parsePhone(input).valid.
isValidPhone('901234567'); // true
isValidPhone('+998 33 123 45 67'); // true
isValidPhone('90123'); // false — incomplete
isValidPhone('121234567'); // false — no such operatorUZ_OPERATOR_CODES
const UZ_OPERATOR_CODES: readonly [
'33',
'50',
'55',
'77',
'88',
'90',
'91',
'93',
'94',
'95',
'97',
'98',
'99',
];
type UzOperatorCode = (typeof UZ_OPERATOR_CODES)[number];Mobile operator codes — the two digits after +998. parsePhone validates against this list.
isValidEmail
function isValidEmail(input: string | null | undefined): boolean;A practical check: name@domain.tld, one @, no spaces, a TLD of at least 2 characters.
Surrounding spaces are ignored. It doesn't chase every corner of the RFC — it catches the
mistakes people actually make.
isValidEmail('sarvar@mail.uz'); // true
isValidEmail(' sarvar@mail.uz '); // true
isValidEmail('sarvar@mail'); // false — no TLD
isValidEmail('sar var@mail.uz'); // false — contains a space
isValidEmail('a@b.c'); // false — one-letter TLDBank cards
To'g'ri namuna: 4111 1111 1111 1111
'use client';
import { detectCardBrand, formatCardNumber, isValidCardNumber } from '@akhatmirzo/core';
import { Badge, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const BRANDS = { uzcard: 'Uzcard', humo: 'Humo', visa: 'Visa', mastercard: 'Mastercard' };
export default function CoreCardValidate() {
const [digits, setDigits] = useState('8600');
const brand = detectCardBrand(digits);
const complete = digits.length === 16;
const valid = isValidCardNumber(digits);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Field
label="Karta raqami"
description="To'g'ri namuna: 4111 1111 1111 1111"
error={complete && !valid ? "Karta raqami noto'g'ri" : undefined}
>
<Input
value={formatCardNumber(digits)}
onChange={(e) => setDigits(e.target.value.replace(/\D/g, '').slice(0, 16))}
inputMode="numeric"
placeholder="0000 0000 0000 0000"
suffix={brand ? BRANDS[brand] : undefined}
/>
</Field>
<div className="flex flex-wrap gap-2">
<Badge color={brand ? 'info' : 'neutral'}>{brand ? BRANDS[brand] : "Brend noma'lum"}</Badge>
<Badge color={!complete ? 'neutral' : valid ? 'success' : 'danger'} dot>
{!complete ? `${digits.length}/16` : valid ? "Luhn: to'g'ri" : 'Luhn: xato'}
</Badge>
</div>
</div>
);
}
detectCardBrand
function detectCardBrand(input: string | null | undefined): CardBrand | null;
// CardBrand = 'uzcard' | 'humo' | 'visa' | 'mastercard'Detects the card brand from the first digits — even for an incomplete number, so you can show the logo while the user types.
| Brand | Starts with |
|---|---|
uzcard | 8600 |
humo | 9860 |
visa | 4 |
mastercard | 51–55, 2221–2720 |
detectCardBrand('8600 12'); // 'uzcard'
detectCardBrand('9860'); // 'humo'
detectCardBrand('4111'); // 'visa'
detectCardBrand('2221'); // 'mastercard'
detectCardBrand('86'); // null — not clear yetisValidCardNumber
function isValidCardNumber(input: string | null | undefined): boolean;Checks a card number: exactly 16 digits (spaces and dashes allowed) and a passing Luhn checksum. It only catches typos — it can't tell whether the card exists or is active.
isValidCardNumber('4111 1111 1111 1111'); // true
isValidCardNumber('4111-1111-1111-1111'); // true
isValidCardNumber('4111 1111 1111 1112'); // false — Luhn fails
isValidCardNumber('411111111111'); // false — 12 digitsluhnCheck
function luhnCheck(digits: string): boolean;The bare Luhn algorithm. The input must be digits only — for strings with spaces, use
isValidCardNumber.
luhnCheck('4111111111111111'); // true
luhnCheck('4111 1111 1111 1111'); // false — contains spacesPasswords
- ✓ Kamida 8 belgi
- ✓ Kichik harf
- ✓ Katta harf
- ✓ Raqam
- ○ Belgi (!@#…)
'use client';
import { passwordStrength } from '@akhatmirzo/core';
import { Badge, Field, Input } from '@akhatmirzo/ui';
import { useState } from 'react';
const BAR = ['bg-danger', 'bg-danger', 'bg-warning', 'bg-success', 'bg-success'];
const COLOR = ['danger', 'danger', 'warning', 'success', 'success'] as const;
const CHECKS = {
length: 'Kamida 8 belgi',
lower: 'Kichik harf',
upper: 'Katta harf',
digit: 'Raqam',
symbol: 'Belgi (!@#…)',
};
export default function CorePassword() {
const [password, setPassword] = useState('Parol123');
const { score, label, checks } = passwordStrength(password);
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<Field label="Parol">
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</Field>
<div className="flex gap-1" aria-hidden>
{[1, 2, 3, 4].map((i) => (
<span
key={i}
className={`h-1.5 flex-1 rounded-full ${password && i <= Math.max(score, 1) ? BAR[score] : 'bg-surface-muted'}`}
/>
))}
</div>
<div className="flex items-center gap-2">
<Badge color={COLOR[score]}>{label}</Badge>
<span className="text-xs text-text-muted">score: {score}/4</span>
</div>
<ul className="flex flex-col gap-1 text-sm">
{(Object.keys(CHECKS) as (keyof typeof CHECKS)[]).map((key) => (
<li key={key} className={checks[key] ? 'text-text' : 'text-text-muted'}>
{checks[key] ? '✓' : '○'} {CHECKS[key]}
</li>
))}
</ul>
</div>
);
}
passwordStrength
function passwordStrength(pw: string | null | undefined): PasswordStrength;
interface PasswordStrength {
score: 0 | 1 | 2 | 3 | 4;
label: 'Juda zaif' | 'Zaif' | "O'rtacha" | 'Yaxshi' | 'Kuchli';
checks: { length: boolean; lower: boolean; upper: boolean; digit: boolean; symbol: boolean };
}Rates a password. The score works like this:
- the number of character kinds used (lowercase, uppercase, digit, symbol) minus 1;
- +1 at 12 characters or more, and another +1 at 16 or more;
- a password shorter than 8 characters never goes above 1; the result stays within 0–4.
Cyrillic letters count as letters. checks.length means at least 8 characters; symbol is any
character that isn't a letter, digit or space. The label is in Uzbek, ready to show in the UI:
"Very weak", "Weak", "Fair", "Good", "Strong".
passwordStrength('abc').label; // 'Juda zaif'
passwordStrength('parol123').label; // 'Zaif'
passwordStrength('Parol123').label; // "O'rtacha"
passwordStrength('Parol123!').label; // 'Yaxshi'
passwordStrength('Parol123!Uzb').label; // 'Kuchli'
passwordStrength('Parol123').checks;
// { length: true, lower: true, upper: true, digit: true, symbol: false }Guidance, not a rule
passwordStrength is there to help the user: a meter and a "what to add" list. Set the minimum
yourself (for example score >= 2) and enforce it on the server too.