parent report page with two charts, Jalali birth date picker, user ID and invoices in profile
- the parents section is gone: no parent login, invite codes or "I'm a parent" button; "گزارشِ والدین" in the profile shows the week against the previous one (praise line, six tiles, minutes-per-day and right/wrong-per-day charts, a table of the numbers) - birth date as day / month / year selects (Jalali, leap Esfand handled) instead of age; adults (19+) are asked which grade's math they want to learn - user ID under the name, copyable - "پرداختها و فاکتورها": every order with invoice number, discount, amount and reference code - tutor: when the free daily quota runs out, a button to the subscription page instead of "retry" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
119
src/components/BirthDatePicker.tsx
Normal file
119
src/components/BirthDatePicker.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toEnDigits, toFa } from '@/lib/format';
|
||||
|
||||
/**
|
||||
* تاریخِ تولدِ شمسی با سه فهرستِ روز/ماه/سال (از ۱۴۰۵-۰۷ به جای «چند سالته؟»).
|
||||
* <select> عمداً بومی است: روی گوشی همان چرخِ آشنای خودِ گوشی باز میشود و تقویمِ
|
||||
* ماهانهای که برای تاریخِ سی سال پیش باید دهها بار ورق بخورد لازم نیست.
|
||||
*
|
||||
* سرور تاریخ را شمسی میگیرد («1385-04-12») و خودش به میلادی تبدیل میکند
|
||||
* (accounts/jalali.py)؛ اینجا هیچ تبدیلی انجام نمیشود، فقط تعدادِ روزهای ماه.
|
||||
*/
|
||||
export const JALALI_MONTHS = [
|
||||
'فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور',
|
||||
'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند',
|
||||
];
|
||||
/** همان بازهی سرور (accounts/serializers.py: MIN_AGE / MAX_AGE). */
|
||||
const MIN_AGE = 5;
|
||||
const MAX_AGE = 99;
|
||||
/** از این سن به بعد فرمِ آشنایی بهجای «کدوم پایهای؟» میپرسد «ریاضیِ کدوم پایه؟». */
|
||||
export const ADULT_AGE = 19;
|
||||
|
||||
type Parts = { y: number; m: number; d: number };
|
||||
|
||||
function jalaliParts(date: Date): Parts {
|
||||
const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric', month: 'numeric', day: 'numeric' }).formatToParts(date);
|
||||
const get = (type: string) => parseInt(toEnDigits(parts.find((p) => p.type === type)?.value ?? '0'), 10);
|
||||
return { y: get('year'), m: get('month'), d: get('day') };
|
||||
}
|
||||
|
||||
export const todayJalali = () => jalaliParts(new Date());
|
||||
|
||||
/** کبیسه یعنی سیامِ اسفند وجود دارد؛ آن روز همیشه یکی از ۱۹ تا ۲۱ مارسِ سالِ بعدِ میلادی است. */
|
||||
function isLeap(y: number): boolean {
|
||||
return [19, 20, 21].some((day) => {
|
||||
const p = jalaliParts(new Date(y + 622, 2, day, 12));
|
||||
return p.y === y && p.m === 12 && p.d === 30;
|
||||
});
|
||||
}
|
||||
|
||||
function monthLength(y: number, m: number): number {
|
||||
if (m <= 6) return 31;
|
||||
if (m <= 11) return 30;
|
||||
return isLeap(y) ? 30 : 29;
|
||||
}
|
||||
|
||||
function parse(value: string | null | undefined): Parts | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '');
|
||||
return match ? { y: +match[1], m: +match[2], d: +match[3] } : null;
|
||||
}
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** «1385-04-12» → «۱۲ تیر ۱۳۸۵». */
|
||||
export function formatBirthDate(value: string | null): string {
|
||||
const p = parse(value);
|
||||
return p ? `${toFa(p.d)} ${JALALI_MONTHS[p.m - 1]} ${toFa(p.y)}` : '';
|
||||
}
|
||||
|
||||
/** سنِ کامل از تاریخِ تولد؛ برای کاربرِ قدیمی که فقط سال داده، تقریبی از سال. */
|
||||
export function ageOf(birthDate: string | null, birthYear: number | null): number | null {
|
||||
const today = todayJalali();
|
||||
const born = parse(birthDate);
|
||||
if (born) return today.y - born.y - (today.m < born.m || (today.m === born.m && today.d < born.d) ? 1 : 0);
|
||||
return birthYear ? today.y - birthYear : null;
|
||||
}
|
||||
|
||||
export function BirthDatePicker({
|
||||
value,
|
||||
fallbackYear = null,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
/** سالِ تولدِ کاربرِ قدیمی (بیتاریخ) تا فهرستِ سال خالی باز نشود. */
|
||||
fallbackYear?: number | null;
|
||||
/** فقط وقتی هر سه انتخاب شدهاند تاریخ میدهد؛ تا آن موقع null. */
|
||||
onChange: (value: string | null) => void;
|
||||
}) {
|
||||
const initial = parse(value);
|
||||
const [y, setY] = useState<number | ''>(initial?.y ?? fallbackYear ?? '');
|
||||
const [m, setM] = useState<number | ''>(initial?.m ?? '');
|
||||
const [d, setD] = useState<number | ''>(initial?.d ?? '');
|
||||
// والد هر بار تابعِ تازه میسازد؛ با ref، تغییرِ تابع خودش onChange را دوباره صدا نمیزند.
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const thisYear = todayJalali().y;
|
||||
const years = Array.from({ length: MAX_AGE - MIN_AGE + 1 }, (_, i) => thisYear - MIN_AGE - i);
|
||||
const days = monthLength(typeof y === 'number' ? y : thisYear, typeof m === 'number' ? m : 1);
|
||||
|
||||
// سیویکِ مهر یا سیامِ اسفندِ غیرکبیسه با عوض شدنِ ماه/سال معتبر نمیماند.
|
||||
useEffect(() => {
|
||||
if (typeof d === 'number' && d > days) setD(days);
|
||||
}, [d, days]);
|
||||
|
||||
useEffect(() => {
|
||||
const complete = y !== '' && m !== '' && d !== '' && d <= days;
|
||||
onChangeRef.current(complete ? `${y}-${pad(m)}-${pad(d)}` : null);
|
||||
}, [y, m, d, days]);
|
||||
|
||||
const select = 'w-full appearance-none rounded-2xl border-2 bg-white px-3 py-3.5 text-center text-sm font-bold outline-none focus:border-coral';
|
||||
const tone = (v: number | '') => (v === '' ? 'border-line text-muted' : 'border-coral text-ink');
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_1.5fr_1.3fr] gap-2">
|
||||
<select aria-label="روزِ تولد" value={d} onChange={(e) => setD(e.target.value ? +e.target.value : '')} className={`${select} ${tone(d)}`}>
|
||||
<option value="">روز</option>
|
||||
{Array.from({ length: days }, (_, i) => i + 1).map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</select>
|
||||
<select aria-label="ماهِ تولد" value={m} onChange={(e) => setM(e.target.value ? +e.target.value : '')} className={`${select} ${tone(m)}`}>
|
||||
<option value="">ماه</option>
|
||||
{JALALI_MONTHS.map((name, i) => <option key={name} value={i + 1}>{name}</option>)}
|
||||
</select>
|
||||
<select aria-label="سالِ تولد" value={y} onChange={(e) => setY(e.target.value ? +e.target.value : '')} className={`${select} ${tone(y)}`}>
|
||||
<option value="">سال</option>
|
||||
{years.map((n) => <option key={n} value={n}>{toFa(n)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user