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:
@@ -1,66 +0,0 @@
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { currentJalaliYear, toFa } from '@/lib/format';
|
||||
import { sounds } from '@/lib/sound';
|
||||
|
||||
const MIN_AGE = 7;
|
||||
const MAX_AGE = 19;
|
||||
const DEFAULT_AGE = 13;
|
||||
|
||||
/** سرور سالِ تولدِ شمسی میخواهد، نه سن. */
|
||||
export const birthYearFromAge = (age: number) => currentJalaliYear() - age;
|
||||
|
||||
export function ageFromBirthYear(year: number | null | undefined): number | null {
|
||||
if (!year) return null;
|
||||
const age = currentJalaliYear() - year;
|
||||
return age >= MIN_AGE && age <= MAX_AGE ? age : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* انتخابِ سن با دو دکمهی − و +. جای «چرخِ» قبلی نشست که عددِ وسطش با مقدارِ
|
||||
* واقعی نمیخواند و کلیک روی عددهایش کار نمیکرد.
|
||||
*/
|
||||
export function AgePicker({ value, onChange }: { value: number | null; onChange: (age: number) => void }) {
|
||||
const current = value ?? DEFAULT_AGE;
|
||||
const step = (delta: number) => {
|
||||
const next = Math.max(MIN_AGE, Math.min(MAX_AGE, current + delta));
|
||||
if (next !== current || value === null) {
|
||||
sounds.tick();
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-2xl border-2 border-line bg-white p-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="کمتر"
|
||||
onClick={() => step(-1)}
|
||||
disabled={value !== null && current <= MIN_AGE}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
||||
>
|
||||
<Minus size={20} strokeWidth={3} />
|
||||
</button>
|
||||
<div className="text-center" aria-live="polite">
|
||||
{value === null ? (
|
||||
<button type="button" onClick={() => onChange(DEFAULT_AGE)} className="text-sm font-bold text-muted">
|
||||
بزن تا انتخاب کنی
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-3xl font-black text-coral">{toFa(value)}</span>
|
||||
<span className="mr-1 text-sm font-bold text-muted">سال</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="بیشتر"
|
||||
onClick={() => step(1)}
|
||||
disabled={value !== null && current >= MAX_AGE}
|
||||
className="flex h-12 w-12 items-center justify-center rounded-xl bg-soft text-coral disabled:opacity-30"
|
||||
>
|
||||
<Plus size={20} strokeWidth={3} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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