13 Commits

Author SHA1 Message Date
0ca7b39e49 point production at mathapi.tadrisino2.ir and add a one-command deploy
The API moved to mathapi.tadrisino2.ir on 2026-09-26; api.math.tadrisino2.ir
no longer resolves. .env.production is the only place that names it.

deploy.sh fetches, runs npm ci and the type-check, builds into dist.next and
swaps it in only when everything succeeded, so the site is never served half
a build. The previous build stays in dist.prev for rollback, and a
hand-edited dist/config.js on the server survives the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:12:39 +03:30
348bfecea6 rebuild the app on a typed, modular structure wired to the real API
The whole app lived in one 3,071-line App.tsx on a Figma scaffold (60 unused
packages, 45 unused UI components, no type-check). The live site could not
reach the backend, Persian-keyboard digits were erased from the phone field,
guests were signed out after an hour, "mark read" hit a method the server
does not accept (405), and iOS Safari blocked the payment window.

What changed:
- src/ split into api/ (one typed client: timeouts, GET retry, single-flight
  refresh, SSE), screens/ (one file each), components/, state/, nav/, lib/.
- The API address is configuration only: public/config.js at runtime, then
  VITE_API_BASE_URL at build time, then same-origin /api. No domain in code.
- Responsive shell instead of a fixed 390x844 phone frame; self-hosted
  Vazirmatn instead of Google Fonts; strict TypeScript, `npm run build`
  type-checks first.
- Everything on the handoff list: tutor answers keep their line breaks, have
  thumbs up/down, follow-ups, "from the book" badge, retry, history that
  survives tab switches and reloads, "why was I wrong" from a wrong answer
  with questionId; question reports; hearts with maxHearts/unlimited and a
  no-hearts screen with the refill time; result screen and a visible error
  when lesson completion fails; subscription status, badge, discount code
  before the gateway and the payment-return message; guest refresh token;
  6-digit OTP with resend timer; age saved as a Jalali birthYear; PATCH
  read-all; leaderboard refetched on open with "you" highlighted; selected
  companion shown across the app; logout that revokes the refresh token.
- New screens for endpoints that had none: profile, streak calendar,
  support tickets and bug reports, parents, invite-a-friend (with ?ref=
  tracking), guest-to-phone upgrade.
- Browser/phone back button walks the screen stack.

Checked with a headless-Chrome walkthrough against a local backend at
390x844 and 360x740 (fresh guest: onboarding, setup, a full lesson, map,
island sheet, every tab and sub-screen, one real tutor question, reload,
logout): no console errors, no failed API calls. Real SMS login and a real
Zarinpal payment were not exercised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:35:48 +03:30
b411b636f4 Merge pull request 'fixed' (#6) from api-branch into main
Reviewed-on: http://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend/pulls/6
2026-09-24 12:33:08 +03:30
267cf5fca0 fixed 2026-09-24 12:30:58 +03:30
d223336638 Merge pull request 'Leaderboard added' (#5) from api-branch into main
Reviewed-on: http://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend/pulls/5
2026-09-21 14:57:22 +03:30
15d110d824 Leaderboard added 2026-09-21 14:55:28 +03:30
a341ffd314 Merge pull request 'api' (#4) from api-branch into main
Reviewed-on: http://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend/pulls/4
2026-09-21 14:20:20 +03:30
74e048cc2a api 2026-09-21 14:14:38 +03:30
4705925022 Merge pull request 'mascots changed' (#2) from updateApp into main
Reviewed-on: http://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend/pulls/2
2026-09-13 22:11:51 +03:30
82c9e870b3 Merge pull request 'افزودنِ لایه‌ی اتصال به API بک‌اند (بدونِ تغییر در App.tsx)' (#1) from AliReza_Zareian/MathApp_frontend:zareian/api-client into main
Reviewed-on: http://gitea.tadrisino2.ir/Tadrisino/MathApp_frontend/pulls/1
2026-09-13 22:07:55 +03:30
f61c58eb76 match the real API contract, verified against a running backend
اولین نسخه از روی حافظه و حدس نوشته شده بود و سه صدای مهم اصلاً کار نمی‌کردند.
این بار هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شد و بعد کلِ
کلاینت در برابرِ یک بک‌اندِ در حالِ اجرا تست شد.

چیزهایی که غلط بودند:
  • ثبتِ پاسخ به /lessons/{id}/answer می‌رفت — چنین مسیری وجود ندارد.
    مسیرِ درست /questions/{id}/answer است و فیلدش selectedOptionId، نه
    questionId/choiceId.
  • /ai/ask فیلدِ userQuestion می‌خواهد، نه question. با نامِ غلط، پرسش
    اصلاً به مدل نمی‌رسید.
  • فیدبک isHelpful بولین می‌خواهد، نه رشته‌ی up/down.
  • UserProfile فیلدهای xp/coins/streak را نداشت (آن‌ها در /user/summary
    هستند) و isGuest/phone را نداشت.
  • شناسه‌ها در پاسخ‌ها رشته‌اند، نه عدد.

پاسخِ دستیار هم usedChunks دارد نه sources؛ خالی بودنش یعنی از کتاب چیزی
پیدا نشده، که همان چیزی‌ست که برچسبِ «بر اساسِ کتاب» به آن نیاز دارد.

questions.report تازه اضافه شد: بک‌اند حالا POST /questions/{id}/report
دارد تا کاربر بتواند ایرادِ خودِ سوال را گزارش کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 21:41:24 +03:30
be9f99d52a add a typed API layer for the Tadrisino backend
اپ تا الان هیچ درخواستی به سرور نمی‌زد و همه‌ی داده‌ها داخلِ خودِ کامپوننت‌ها
هاردکد بود. این کامیت لایه‌ی ارتباط را اضافه می‌کند، بدونِ اینکه هیچ صفحه‌ای
را تغییر دهد.

عمداً فقط فایلِ جدید اضافه شده و App.tsx دست نخورده است، چون کارِ در جریانِ
برنچِ updateApp روی همان فایل است و نباید با هم تصادف کنند. وصل کردنِ
صفحه‌ها به این لایه کارِ بعدی‌ست.

هیچ پکیجِ جدیدی اضافه نشد — فقط fetch استاندارد.

چیزهایی که در client.ts هست برای حالتِ خراب‌اند، نه حالتِ خوب:
  • timeout روی هر درخواست — وگرنه بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا
    مرورگر به سقفِ ۶ درخواستِ همزمان بخورد و اپ قفل شود.
  • backoff با jitter — بدونِ jitter همه‌ی کلاینت‌ها بعد از یک خطا همزمان
    دوباره می‌زنند و موجِ دوم از موجِ اول بدتر است.
  • تکرارِ خودکار فقط روی GET؛ POST فقط وقتی Idempotency-Key دارد (ثبتِ پاسخ،
    سفارش) تا دابل‌کلیکِ کاربر دو بار XP یا دو سفارش نسازد.
  • refresh تک‌نسخه‌ای — ۲۰ درخواستِ همزمانِ ۴۰۱ نباید ۲۰ بار refresh بزنند.
  • ادغامِ GETهای یکسانِ در جریان.
  • احترام به هدرِ Retry-After که بک‌اند موقعِ throttle می‌فرستد.

credentials: 'omit' عمدی‌ست: احرازِ هویت توکنِ Bearer در هدر است و سمتِ سرور
CORS_ALLOW_CREDENTIALS=False تنظیم شده.

.env هم به .gitignore اضافه شد؛ قبلاً فقط node_modules داخلش بود و آدرس/
تنظیماتِ محلی می‌توانست ناخواسته کامیت شود.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:06:28 +03:30
8ac87e40de mascots changed 2026-09-13 11:11:01 +03:30
118 changed files with 6016 additions and 9793 deletions

8
.env.development Normal file
View File

@@ -0,0 +1,8 @@
# حالتِ توسعه (npm run dev). درخواست‌ها به /api می‌روند و Vite آن‌ها را به
# VITE_DEV_PROXY_TARGET پاس می‌دهد، پس CORS در توسعه اصلاً مطرح نیست.
#
# پیش‌فرض بک‌اندِ محلی است (python manage.py runserver). اگر بک‌اندِ محلی ندارید،
# در فایلِ .env.development.local این را به آدرسِ سرور عوض کنید — ولی یادتان
# باشد هرچه آنجا بزنید روی دیتابیسِ واقعی نوشته می‌شود.
VITE_API_BASE_URL=/api
VITE_DEV_PROXY_TARGET=http://127.0.0.1:8000

8
.env.production Normal file
View File

@@ -0,0 +1,8 @@
# آدرسِ API در buildِ پروداکشن. تنها جایی از ریپو که دامنه‌ی بک‌اند نوشته شده.
#
# برای عوض کردنِ دامنه دو راه هست:
# ۱) همین خط را عوض کنید و دوباره build بگیرید.
# ۲) بدونِ build: روی سرور، در dist/config.js مقدارِ apiBaseUrl را بنویسید.
# آن فایل در زمانِ اجرا خوانده می‌شود و بر این مقدار مقدم است.
# اگر nginxِ دامنه‌ی فرانت /api را به بک‌اند پراکسی کند، مقدارِ /api کافی است.
VITE_API_BASE_URL=https://mathapi.tadrisino2.ir/api

9
.gitignore vendored
View File

@@ -1 +1,10 @@
node_modules
dist
# تنظیماتِ شخصیِ هر توسعه‌دهنده — کامیت نشوند
.env
.env.local
.env.*.local
dist.next
dist.prev
.deploy-backups

View File

@@ -1,3 +0,0 @@
This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).

89
README.md Normal file
View File

@@ -0,0 +1,89 @@
# تدریسینو — فرانتِ اپِ ریاضی
اپِ وبِ موبایل‌محورِ تدریسینو (React + TypeScript + Vite + Tailwind). بک‌اندش ریپوی
جداست (`Tadrisino/MathApp`)؛ قراردادِ API در `/api/docs/` همان بک‌اند است.
## راه‌اندازی
```bash
npm install
npm run dev # http://localhost:5173
```
در حالتِ توسعه، درخواست‌های `/api` از خودِ Vite به بک‌اند پاس داده می‌شوند، پس CORS
مطرح نیست. پیش‌فرض بک‌اندِ محلی است (`python manage.py runserver` روی پورتِ ۸۰۰۰).
بدونِ بک‌اندِ محلی، یک فایلِ `.env.development.local` بسازید:
```bash
VITE_DEV_PROXY_TARGET=https://mathapi.tadrisino2.ir
```
⚠️ آن‌وقت هر کاری در اپ بکنید روی دیتابیسِ واقعی نوشته می‌شود.
| دستور | کار |
|---|---|
| `npm run dev` | سرورِ توسعه |
| `npm run typecheck` | بررسیِ نوع‌ها (strict) |
| `npm run build` | type-check و بعد build در `dist/` |
| `npm run preview` | اجرای همان build |
## آدرسِ بک‌اند و عوض کردنِ دامنه
دامنه هیچ‌جای کد نیامده. آدرسِ API به این ترتیب خوانده می‌شود
(`src/config.ts`):
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا. بعد از build هم روی
سرور قابلِ ویرایش است و build دوباره نمی‌خواهد.
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
3. **`/api`** — هم‌دامنه، وقتی nginxِ دامنه‌ی فرانت مسیرِ `/api` را به بک‌اند پراکسی کند.
برای جابه‌جایی دامنه:
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید
`apiBaseUrl: "https://api.new-domain.ir/api"` و کشِ CDN را برای همین فایل پاک کنید.
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
- **سمتِ بک‌اند** هم دامنه‌ی تازه‌ی فرانت باید مجاز باشد (`FRONTEND_URL` در `.env`
بک‌اند). جزئیات در `docs/operations.md` همان ریپو.
## دیپلوی
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو می‌کند):
```bash
./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابه‌جاییِ بی‌وقفه
```
buildِ قبلی در `dist.prev` می‌ماند (برگشت: `rm -rf dist && mv dist.prev dist`)، و
`dist/config.js`ِ ویرایش‌شده روی سرور به buildِ تازه منتقل می‌شود.
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همه‌چیز از `/` بار می‌شود؛
فقط `index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایل‌ها نامِ hashدار دارند).
درگاهِ پرداخت کاربر را به `/?orderId=…&status=…` برمی‌گرداند و لینکِ دعوت
`/?ref=CODE` است؛ هر دو را خودِ اپ می‌خواند.
## ساختار
```
src/
config.ts آدرسِ API (بالا را ببینید)
api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمی‌زند
http.ts timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE
index.ts یک تابع برای هر اندپوینت
types.ts شکلِ پاسخ‌ها، از روی پاسخِ واقعیِ سرور
app/ App (جریانِ ورود) و MainApp (تب‌ها و صفحه‌ها)
nav/ ناوبریِ پشته‌ای، هماهنگ با دکمه‌ی back گوشی
state/ داده‌ی مشترک (پروفایل، نقشه، …) و گفتگوی دستیار
screens/ یک فایل برای هر صفحه
components/ اجزای مشترک (سربرگ، برگه‌ی پایین، …)
mascots/ سه‌یار: سیگما، دلتا، پی
lib/ اعدادِ فارسی، تاریخِ شمسی، صدا، localStorage
```
قاعده‌ها:
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ
`۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، …
- **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور.
- **کلاینت را از حافظه ننویسید.** قرارداد را از `/api/docs/` بک‌اند بخوانید و تغییرِ
`types.ts` را با پاسخِ واقعیِ سرور بسنجید.

View File

@@ -1,120 +0,0 @@
/* KEEP_IN_SYNC(fullscreen/resources/figmake/shadcn/globals.css) */
:root {
--font-size: 16px;
--background: #ffffff;
--foreground: oklch(0.145 0 0);
--card: #ffffff;
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: #030213;
--primary-foreground: oklch(1 0 0);
--secondary: oklch(0.95 0.0058 264.53);
--secondary-foreground: #030213;
--muted: #ececf0;
--muted-foreground: #717182;
--accent: #e9ebef;
--accent-foreground: #030213;
--destructive: #d4183d;
--destructive-foreground: #ffffff;
--border: rgba(0, 0, 0, 0.1);
--input: transparent;
--input-background: #f3f3f5;
--switch-background: #cbced4;
--font-weight-medium: 500;
--font-weight-normal: 400;
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: #030213;
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--font-weight-medium: 500;
--font-weight-normal: 400;
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-input-background: var(--input-background);
--color-switch-background: var(--switch-background);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}

50
deploy.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# انتشارِ فرانت روی سرور: گرفتنِ کدِ تازه، build، و جابه‌جاییِ بی‌وقفه‌ی dist.
#
# ./deploy.sh # شاخه‌ی main
# ./deploy.sh <branch>
#
# build در dist.next ساخته می‌شود و فقط اگر کامل موفق شد جای dist را می‌گیرد؛
# پس سایت حتی چند ثانیه هم بی‌فایل نمی‌ماند. buildِ قبلی در dist.prev می‌ماند:
# برگشت: rm -rf dist && mv dist.prev dist
#
# dist/config.js (آدرسِ API در زمانِ اجرا) اگر روی سرور دستی ویرایش شده باشد،
# به buildِ تازه منتقل می‌شود تا با هر انتشار از دست نرود.
set -euo pipefail
cd "$(dirname "$0")"
BRANCH="${1:-main}"
step() { printf '\n\033[0;32m▸ %s\033[0m\n' "$1"; }
fail() { printf '\033[0;31m✖ %s\033[0m\n' "$1" >&2; exit 1; }
step "گرفتنِ کدِ تازه ($BRANCH)"
git fetch origin "$BRANCH" || fail "به gitea وصل نشد."
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
mkdir -p .deploy-backups
PATCH=".deploy-backups/local-changes-$(date +%Y%m%d-%H%M%S).patch"
git diff > "$PATCH"
printf ' تغییرِ دستی روی سرور بود؛ در %s ذخیره شد.\n' "$PATCH"
fi
git reset --hard "origin/$BRANCH"
git log --oneline -1
step "نصبِ وابستگی‌ها"
npm ci --no-audit --no-fund
step "type-check و build"
rm -rf dist.next
npx tsc --noEmit || fail "type-check شکست خورد؛ سایت دست نخورد."
npx vite build --outDir dist.next --emptyOutDir || fail "build شکست خورد؛ سایت دست نخورد."
if [ -f dist/config.js ] && ! cmp -s dist/config.js public/config.js; then
cp dist/config.js dist.next/config.js
printf ' config.jsِ ویرایش‌شده‌ی سرور نگه داشته شد.\n'
fi
step "جابه‌جایی"
rm -rf dist.prev
[ -d dist ] && mv dist dist.prev
mv dist.next dist
API="$(grep -o 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1 | cut -d: -f2-)"
printf ' منتشر شد. آدرسِ API در build: %s\n' "${API:-(هم‌دامنه /api یا config.js)}"
printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n'

View File

@@ -1,61 +0,0 @@
**Add your own guidelines here**
<!--
System Guidelines
Use this file to provide the AI with rules and guidelines you want it to follow.
This template outlines a few examples of things you can add. You can add your own sections and format it to suit your needs
TIP: More context isn't always better. It can confuse the LLM. Try and add the most important rules you need
# General guidelines
Any general rules you want the AI to follow.
For example:
* Only use absolute positioning when necessary. Opt for responsive and well structured layouts that use flexbox and grid by default
* Refactor code as you go to keep code clean
* Keep file sizes small and put helper functions and components in their own files.
--------------
# Design system guidelines
Rules for how the AI should make generations look like your company's design system
Additionally, if you select a design system to use in the prompt box, you can reference
your design system's components, tokens, variables and components.
For example:
* Use a base font-size of 14px
* Date formats should always be in the format “Jun 10”
* The bottom toolbar should only ever have a maximum of 4 items
* Never use the floating action button with the bottom toolbar
* Chips should always come in sets of 3 or more
* Don't use a dropdown if there are 2 or fewer options
You can also create sub sections and add more specific details
For example:
## Button
The Button component is a fundamental interactive element in our design system, designed to trigger actions or navigate
users through the application. It provides visual feedback and clear affordances to enhance user experience.
### Usage
Buttons should be used for important actions that users need to take, such as form submissions, confirming choices,
or initiating processes. They communicate interactivity and should have clear, action-oriented labels.
### Variants
* Primary Button
* Purpose : Used for the main action in a section or page
* Visual Style : Bold, filled with the primary brand color
* Usage : One primary button per section to guide users toward the most important action
* Secondary Button
* Purpose : Used for alternative or supporting actions
* Visual Style : Outlined with the primary color, transparent background
* Usage : Can appear alongside a primary button for less important actions
* Tertiary Button
* Purpose : Used for the least important actions
* Visual Style : Text-only with no border, using primary color
* Usage : For actions that should be available but not emphasized
-->

View File

@@ -1,22 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Update Educational App Mascot</title>
<meta name="description" content="An educational app featuring an interactive robot mascot that guides students through lessons, skill maps, and quizzes with dynamic feedback to enhance learning." />
<meta name="robots" content="noindex, nofollow" />
<style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#ED6956" />
<meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایه‌ی سوم تا دوازدهم، قدم‌به‌قدم و با دستیارِ هوشمند." />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>تدریسینو</title>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
<script src="/config.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3340
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,90 +1,30 @@
{
"name": "@figma/my-make-file",
"name": "tadrisino-math-app",
"private": true,
"version": "0.0.1",
"version": "2.0.0",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite"
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "11.14.0",
"@emotion/styled": "11.14.1",
"@mui/icons-material": "7.3.5",
"@mui/material": "7.3.5",
"@popperjs/core": "2.11.8",
"@radix-ui/react-accordion": "1.2.3",
"@radix-ui/react-alert-dialog": "1.1.6",
"@radix-ui/react-aspect-ratio": "1.1.2",
"@radix-ui/react-avatar": "1.1.3",
"@radix-ui/react-checkbox": "1.1.4",
"@radix-ui/react-collapsible": "1.1.3",
"@radix-ui/react-context-menu": "2.2.6",
"@radix-ui/react-dialog": "1.1.6",
"@radix-ui/react-dropdown-menu": "2.1.6",
"@radix-ui/react-hover-card": "1.1.6",
"@radix-ui/react-label": "2.1.2",
"@radix-ui/react-menubar": "1.1.6",
"@radix-ui/react-navigation-menu": "1.2.5",
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-progress": "1.1.2",
"@radix-ui/react-radio-group": "1.2.3",
"@radix-ui/react-scroll-area": "1.2.3",
"@radix-ui/react-select": "2.1.6",
"@radix-ui/react-separator": "1.1.2",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-switch": "1.1.3",
"@radix-ui/react-tabs": "1.1.3",
"@radix-ui/react-toggle-group": "1.1.2",
"@radix-ui/react-toggle": "1.1.2",
"@radix-ui/react-tooltip": "1.1.8",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"date-fns": "3.6.0",
"embla-carousel-react": "8.6.0",
"input-otp": "1.4.2",
"lucide-react": "0.487.0",
"motion": "12.23.24",
"next-themes": "0.4.6",
"react-day-picker": "8.10.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-hook-form": "7.55.0",
"react-popper": "2.3.0",
"react-resizable-panels": "2.1.7",
"react-responsive-masonry": "2.7.1",
"react-router": "7.13.0",
"react-slick": "0.31.0",
"recharts": "2.15.2",
"sonner": "2.0.3",
"tailwind-merge": "3.2.0",
"tw-animate-css": "1.3.8",
"vaul": "1.1.2"
"react": "18.3.1",
"react-dom": "18.3.1",
"vazirmatn": "33.0.3"
},
"devDependencies": {
"@tailwindcss/vite": "4.1.12",
"@types/canvas-confetti": "1.9.0",
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"@vitejs/plugin-react": "4.7.0",
"tailwindcss": "4.1.12",
"typescript": "5.9.3",
"vite": "6.3.5"
},
"peerDependencies": {
"react": "18.3.1",
"react-dom": "18.3.1"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
},
"pnpm": {
"overrides": {
"vite": "6.3.5"
}
}
}

View File

@@ -1,10 +0,0 @@
packages:
- '.'
supportedArchitectures:
os:
- linux
cpu:
- x64
- arm64
libc:
- glibc

9
public/config.js Normal file
View File

@@ -0,0 +1,9 @@
// تنظیماتِ زمانِ اجرای اپ — بعد از build هم روی سرور قابلِ ویرایش است.
//
// apiBaseUrl خالی یعنی همان آدرسی که موقعِ build در .env.production بوده.
// برای جابه‌جا کردنِ بک‌اند بدونِ build دوباره، آدرسِ کامل را اینجا بنویسید:
// apiBaseUrl: "https://api.new-domain.ir/api",
// اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید.
window.__TADRISINO_CONFIG__ = {
apiBaseUrl: "",
};

1
public/favicon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#ED6956"/><text x="32" y="45" font-size="38" text-anchor="middle" font-family="Georgia,serif" font-weight="bold" fill="#fff">Σ</text></svg>

After

Width:  |  Height:  |  Size: 244 B

339
src/api/http.ts Normal file
View File

@@ -0,0 +1,339 @@
/**
* هسته‌ی ارتباط با بک‌اند. فقط fetch استاندارد، بدونِ پکیج.
*
* هر چیزی که اینجاست برای یک حالتِ خراب است، نه حالتِ خوب:
* • timeout — بک‌اندِ کند نباید سوکت‌ها را تا ابد باز نگه دارد.
* • تکرارِ خودکار فقط برای GET، با backoff و jitter؛ تکرارِ POST یعنی ثبتِ دوباره.
* • refreshِ تک‌نسخه‌ای — ده درخواستِ هم‌زمان با ۴۰۱ یک refresh می‌زنند، نه ده تا.
* • ادغامِ GETهای هم‌زمانِ یکسان.
* • وقتی نشست به‌کل باطل شد، onSignedOut خبر می‌دهد تا اپ به صفحه‌ی ورود برگردد.
*/
export interface Tokens {
token: string;
refreshToken: string;
}
export class ApiError extends Error {
retryAfterMs?: number;
constructor(
readonly status: number,
readonly detail: string,
readonly body: Record<string, unknown> = {},
) {
super(detail);
this.name = 'ApiError';
}
/** کدِ ماشینیِ خطا، اگر سرور داده باشد (مثلاً "no_hearts"). */
get code(): string | undefined {
return typeof this.body.code === 'string' ? this.body.code : undefined;
}
get isNetwork() { return this.status === 0; }
get isUnauthorized() { return this.status === 401; }
get isRateLimited() { return this.status === 429; }
get isProviderDown() { return this.status === 503; }
}
export interface TokenStorage {
get(): Tokens | null;
set(tokens: Tokens | null): void;
}
const STORAGE_KEY = 'tadrisino.tokens';
/** localStorage در حالتِ خصوصیِ بعضی مرورگرها throw می‌کند؛ اپ نباید بخوابد. */
export const localTokenStorage: TokenStorage = {
get() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
}
},
set(tokens) {
try {
if (tokens) localStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* ذخیره نشد؛ نشست فقط تا بستنِ صفحه می‌ماند */
}
},
};
export interface RequestOptions {
timeoutMs?: number;
signal?: AbortSignal;
/** POST را هم تکرارپذیر می‌کند؛ فقط همراهِ idempotencyKey معنا دارد. */
retry?: boolean;
idempotencyKey?: string;
}
export interface HttpClientOptions {
baseUrl: string;
storage?: TokenStorage;
timeoutMs?: number;
maxRetries?: number;
}
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function backoffMs(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000);
return Math.random() * Math.min(1000 * 2 ** attempt, 8000);
}
function parseRetryAfter(header: string | null): number | undefined {
if (!header) return undefined;
const seconds = Number(header);
if (Number.isFinite(seconds)) return seconds * 1000;
const at = Date.parse(header);
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
}
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export function newIdempotencyKey(): string {
const c = globalThis.crypto;
if (c && 'randomUUID' in c) return c.randomUUID();
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** AbortSignal.any هنوز همه‌جا نیست؛ جایگزینِ کوچکش. */
function anySignal(signals: AbortSignal[]): AbortSignal {
const native = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
if (native) return native(signals);
const controller = new AbortController();
for (const s of signals) {
if (s.aborted) {
controller.abort(s.reason);
break;
}
s.addEventListener('abort', () => controller.abort(s.reason), { once: true });
}
return controller.signal;
}
function timeoutSignal(ms: number): AbortSignal {
if ('timeout' in AbortSignal) return AbortSignal.timeout(ms);
const controller = new AbortController();
setTimeout(() => controller.abort(), ms);
return controller.signal;
}
async function parseBody(response: Response): Promise<Record<string, unknown>> {
const text = await response.text();
if (!text) return {};
try {
const parsed: unknown = JSON.parse(text);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
// HTML به‌جای JSON: معمولاً ۵۰۲ِ nginx، یا آدرسِ API اشتباه است و
// سرورِ فرانت index.html را برگردانده.
return {};
}
}
function errorFrom(response: Response, body: Record<string, unknown>): ApiError {
const detail =
(typeof body.detail === 'string' && body.detail) ||
(typeof body.error === 'string' && body.error) ||
firstFieldError(body) ||
(response.status >= 500 ? 'سرور موقتاً در دسترس نیست' : `خطای ${response.status}`);
const error = new ApiError(response.status, detail, body);
error.retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
return error;
}
/** خطای اعتبارسنجیِ DRF به شکلِ {field: ["پیام"]} است. */
function firstFieldError(body: Record<string, unknown>): string | undefined {
for (const value of Object.values(body)) {
if (Array.isArray(value) && typeof value[0] === 'string') return value[0];
}
return undefined;
}
export function createHttpClient(options: HttpClientOptions) {
const baseUrl = options.baseUrl.replace(/\/+$/, '');
const storage = options.storage ?? localTokenStorage;
const defaultTimeout = options.timeoutMs ?? 15_000;
const maxRetries = options.maxRetries ?? 2;
const inFlight = new Map<string, Promise<unknown>>();
const signedOutListeners = new Set<() => void>();
let refreshing: Promise<Tokens | null> | null = null;
function authHeaders(): Record<string, string> {
const token = storage.get()?.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
function signOut() {
storage.set(null);
signedOutListeners.forEach((listener) => listener());
}
async function refreshTokens(): Promise<Tokens | null> {
const current = storage.get();
if (!current?.refreshToken) return null;
if (refreshing) return refreshing;
refreshing = (async () => {
try {
const response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: current.refreshToken }),
signal: timeoutSignal(defaultTimeout),
credentials: 'omit',
});
if (!response.ok) return null;
const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
storage.set(next);
return next;
} catch {
return null;
} finally {
queueMicrotask(() => {
refreshing = null;
});
}
})();
return refreshing;
}
async function send(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
): Promise<Response> {
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
const timer = timeoutSignal(opts.timeoutMs ?? defaultTimeout);
try {
return await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: opts.signal ? anySignal([timer, opts.signal]) : timer,
// احرازِ هویت هدرِ Bearer است، نه کوکی؛ سرور هم CORS را بدونِ credentials دارد.
credentials: 'omit',
});
} catch (err) {
if (opts.signal?.aborted) throw err;
const timedOut = err instanceof DOMException && (err.name === 'TimeoutError' || err.name === 'AbortError');
throw new ApiError(0, timedOut ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
}
}
/** یک درخواست، با یک‌بار refresh در صورتِ ۴۰۱. */
async function sendWithRefresh(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
): Promise<Response> {
const hadToken = Boolean(storage.get()?.token);
let response = await send(method, path, body, opts);
if (response.status === 401 && hadToken) {
const refreshed = await refreshTokens();
if (refreshed) response = await send(method, path, body, opts);
else signOut();
}
return response;
}
async function request<T>(method: string, path: string, body?: unknown, opts: RequestOptions = {}): Promise<T> {
const retriable = opts.retry ?? method === 'GET';
for (let attempt = 0; ; attempt++) {
try {
const response = await sendWithRefresh(method, path, body, opts);
const parsed = await parseBody(response);
if (!response.ok) throw errorFrom(response, parsed);
return parsed as T;
} catch (err) {
if (!(err instanceof ApiError)) throw err;
const worthRetrying = err.isNetwork || RETRIABLE_STATUS.has(err.status);
if (!retriable || !worthRetrying || attempt >= maxRetries || opts.signal?.aborted) throw err;
await sleep(backoffMs(attempt, err.retryAfterMs));
}
}
}
function get<T>(path: string, opts: RequestOptions = {}): Promise<T> {
// درخواستِ لغوشدنی ادغام نمی‌شود؛ لغوِ یکی نباید دیگری را بکشد.
if (opts.signal) return request<T>('GET', path, undefined, opts);
const existing = inFlight.get(path);
if (existing) return existing as Promise<T>;
const promise = request<T>('GET', path, undefined, opts).finally(() => inFlight.delete(path));
inFlight.set(path, promise);
return promise;
}
/**
* درخواستِ Server-Sent Events (پاسخِ تکه‌تکه‌ی دستیار).
* رویدادها با خطِ خالی جدا می‌شوند: `event: <نام>\ndata: <json>`.
*/
async function stream(
path: string,
body: unknown,
onEvent: (event: string, data: Record<string, unknown>) => void,
opts: RequestOptions = {},
): Promise<void> {
const response = await sendWithRefresh('POST', path, body, { timeoutMs: 120_000, ...opts });
if (!response.ok || !response.body) {
throw errorFrom(response, await parseBody(response));
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() ?? '';
for (const part of parts) {
const event = /^event: (.+)$/m.exec(part)?.[1];
const data = /^data: (.+)$/m.exec(part)?.[1];
if (!event || !data) continue;
try {
onEvent(event, JSON.parse(data) as Record<string, unknown>);
} catch (err) {
if (err instanceof ApiError) throw err;
/* تکه‌ی ناقص؛ نادیده */
}
}
}
}
return {
baseUrl,
storage,
get,
post: <T>(path: string, body?: unknown, opts?: RequestOptions) => request<T>('POST', path, body, opts),
patch: <T>(path: string, body?: unknown, opts?: RequestOptions) => request<T>('PATCH', path, body, opts),
/** POSTی که چیزی ثبت می‌کند: با کلیدِ یکتا، پس تکرارش دوبار ثبت نمی‌کند. */
create: <T>(path: string, body?: unknown, opts: RequestOptions = {}) =>
request<T>('POST', path, body, {
...opts,
retry: true,
idempotencyKey: opts.idempotencyKey ?? newIdempotencyKey(),
}),
stream,
onSignedOut(listener: () => void) {
signedOutListeners.add(listener);
return () => {
signedOutListeners.delete(listener);
};
},
};
}
export type HttpClient = ReturnType<typeof createHttpClient>;

184
src/api/index.ts Normal file
View File

@@ -0,0 +1,184 @@
/**
* تنها درِ ارتباط با بک‌اند. هیچ جای دیگرِ اپ fetch نمی‌زند.
*
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است.
*/
import { resolveApiBaseUrl } from '@/config';
import { ApiError, createHttpClient, newIdempotencyKey } from './http';
import type * as T from './types';
export { ApiError };
export type * from './types';
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl() });
const q = (params: Record<string, string | number | undefined>) => {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '');
return entries.length ? `?${new URLSearchParams(entries.map(([k, v]) => [k, String(v)]))}` : '';
};
function saveTokens(data: T.AuthResponse) {
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
return data;
}
export const api = {
auth: {
isSignedIn: () => Boolean(http.storage.get()?.token),
/** مهمان: فقط با شناسه‌ی دستگاه. refreshToken هم می‌گیرد، پس بعد از یک ساعت نمی‌افتد. */
guest: (deviceId: string) =>
http.post<T.AuthResponse>('/auth/session', { type: 'guest', deviceId }).then(saveTokens),
/** گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمی‌گردد. */
requestOtp: (phone: string) =>
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }),
/**
* گامِ ۲: تأییدِ کد. اگر الان مهمان باشد، سرور همان کاربر را نگه می‌دارد و
* پیشرفتش از دست نمی‌رود (توکنِ مهمان همراهِ همین درخواست می‌رود).
*/
login: (phone: string, otp: string) =>
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens),
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() {
const refreshToken = http.storage.get()?.refreshToken;
try {
await http.post('/auth/logout', refreshToken ? { refreshToken } : {});
} finally {
http.storage.set(null);
}
},
},
grades: () => http.get<{ grades: T.Grade[] }>('/grades').then((r) => r.grades),
profile: {
get: () => http.get<T.UserProfile>('/user/profile'),
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>>) =>
http.patch<T.UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
},
summary: () => http.get<T.UserSummary>('/user/summary'),
hearts: () => http.get<T.Hearts>('/user/hearts'),
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'),
/** بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. */
home: () => http.get<T.HomeResponse>('/home'),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${nodeId}/lessons`),
lesson: {
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${lessonId}/intro`),
questions: (lessonId: number) =>
http.get<{ questions: T.Question[] }>(`/lessons/${lessonId}/questions`).then((r) => r.questions),
/**
* کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا
* امتیاز و قلب دوبار حساب نشود.
*/
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) =>
http.post<T.AnswerResult>(
`/questions/${questionId}/answer`,
{ selectedOptionId: Number(selectedOptionId) },
{ idempotencyKey, retry: true },
),
/** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.LessonComplete>(`/lessons/${lessonId}/complete`, result, { idempotencyKey }),
report: (questionId: string, reason: T.ReportReason, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/questions/${questionId}/report`,
{ reason, ...(note ? { note } : {}) },
),
},
leaderboard: (period: T.LeaderboardPeriod) =>
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries),
characters: {
list: () => http.get<{ characters: T.Character[] }>('/characters').then((r) => r.characters),
select: (characterId: string) =>
http.post<{ characterId: string; selected: boolean }>('/characters/select', { characterId }),
},
ai: {
/**
* پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده.
*/
async ask(
input: { userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string },
onDelta: (text: string) => void,
signal?: AbortSignal,
): Promise<T.AskDone> {
let done: T.AskDone | null = null;
await http.stream(
'/ai/ask',
{ ...input, lessonId: input.lessonId?.toString(), stream: true },
(event, data) => {
if (event === 'chunk') onDelta(String(data.delta ?? ''));
else if (event === 'done') done = data as unknown as T.AskDone;
else if (event === 'error') throw new ApiError(503, String(data.detail ?? 'دستیار در دسترس نیست'), data);
},
{ signal },
);
if (!done) throw new ApiError(0, 'پاسخ کامل نرسید');
return done;
},
feedback: (messageId: string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(`/ai/messages/${messageId}/feedback`, {
isHelpful,
...(reason ? { reason } : {}),
}),
history: (conversationId: string) =>
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>(
`/ai/conversations/${conversationId}/messages`,
),
},
notifications: {
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'),
markRead: (id: number) => http.patch(`/notifications/${id}/read`),
markAllRead: () => http.patch('/notifications/read-all'),
},
subscription: {
plans: () => http.get<{ plans: T.Plan[] }>('/subscription/plans').then((r) => r.plans),
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
/** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${orderId}`),
},
support: {
createTicket: (subject: string, category: string) =>
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${ticketId}`),
reply: (ticketId: string, text: string) =>
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${ticketId}/messages`, { text }),
bugReport: (title: string, description: string) =>
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
},
family: {
link: () => http.get<T.FamilyLink>('/family/link'),
invite: (childPhone: string) =>
http.post<{ familyLinkId: string; inviteCode: string; status: string }>('/family/link/invite', { childPhone }),
accept: (inviteCode: string) =>
http.post<{ familyLinkId: string; status: string }>('/family/link/accept', { inviteCode }),
weeklyReport: () => http.get<T.FamilyWeeklyReport>('/family/weekly-report'),
settings: () => http.get<T.FamilySettings>('/family/settings'),
updateSettings: (patch: Partial<T.FamilySettings>) => http.patch('/family/settings', patch),
},
affiliate: {
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
/** کاربرِ تازه (با شماره، نه مهمان) را به صاحبِ کدِ معرف وصل می‌کند. */
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
},
};

275
src/api/types.ts Normal file
View File

@@ -0,0 +1,275 @@
/**
* شکلِ پاسخ‌های بک‌اند.
*
* از روی پاسخِ واقعیِ سرور ثبت شده (۲۰۲۶-۰۹-۲۶)، نه از حدس. قراردادِ کامل در
* /api/docs/ همان بک‌اند است؛ اگر فیلدی آنجا عوض شد، اینجا هم عوض کنید.
*
* دو نکته که غافلگیر می‌کنند:
* • شناسه‌ها در پاسخ‌ها رشته‌اند، ولی در بدنه‌ی درخواست گاهی عدد خواسته می‌شوند.
* • birthYear سالِ شمسی است (سرور بیشتر از ۱۵۰۰ را رد می‌کند).
*/
export interface AuthResponse {
userId: string;
token: string;
refreshToken: string;
isNewUser?: boolean;
isGuest?: boolean;
}
export interface Grade {
id: number;
number: number | null;
title: string;
icon: string;
color: string;
lessonCount: number;
}
export interface UserProfile {
userId: string;
phone: string | null;
name: string;
avatar: string;
isGuest: boolean;
gradeId: number | null;
birthYear: number | null;
parentPhone: string;
bio: string;
}
export interface UserSummary {
userId: string;
name: string;
avatar: string;
gradeId: number | null;
xp: number;
coins: number;
streak: { current: number; longest: number };
subscription: string | null;
}
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode {
id: string;
title: string;
/** نامِ آیکنِ lucide (مثلاً "Grid3x3")؛ ممکن است خالی باشد. */
icon: string;
color: string;
status: NodeStatus;
/** درس‌های سوال‌دارِ این جزیره؛ اولی همیشه درسِ بعدی است. */
lessonIds: number[];
nextLessonId: number | null;
}
export interface HomeUnit {
id: string;
title: string;
blurb: string;
nodes: HomeNode[];
}
export interface HomeStats {
progressPercent: number;
xp: number;
coins: number;
}
export interface HomeResponse {
units: HomeUnit[];
stats: HomeStats;
}
export interface NodeLesson {
id: string;
kind: 'quiz' | 'video';
title: string;
meta: string;
done: boolean;
}
export interface NodeLessons {
nodeId: string;
title: string;
lessons: NodeLesson[];
}
export interface LessonIntro {
lessonId: string;
title: string;
introText: string;
mascotAnimation: string;
/** صفر یعنی متنِ درس هست ولی سوالش هنوز ساخته نشده. */
questionCount: number;
kind: 'quiz' | 'video';
example: string;
steps: string[];
videoUrl: string;
durationMinutes: number | null;
}
export interface QuestionOption {
id: string;
value: string;
}
export interface Question {
id: string;
type: string;
prompt: string;
options: QuestionOption[];
}
export interface AnswerResult {
correct: boolean;
correctOptionId: string;
xpEarned: number;
heartsRemaining: number;
mascotMood: string;
}
export interface LessonComplete {
stars: number;
xpEarned: number;
coinsEarned: number;
stats: HomeStats;
}
export type ReportReason = 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
export interface Hearts {
currentHearts: number;
maxHearts: number;
nextRefillAt: string | null;
fullRefillAt: string | null;
isUnlimited: boolean;
}
export interface StreakCalendar {
currentStreak: number;
longestStreak: number;
/** تاریخ‌های میلادی به شکلِ YYYY-MM-DD */
activeDays: string[];
}
export type LeaderboardPeriod = 'weekly' | 'all_time';
export interface LeaderboardEntry {
rank: number;
userId: string;
name: string;
xpInPeriod: number;
}
export interface Character {
characterId: string;
slug: string;
name: string;
icon: string;
unlockXp: number;
unlocked: boolean;
selected: boolean;
}
export interface UsedChunk {
title: string;
summary: string;
}
/** رویدادِ پایانیِ استریمِ دستیار. */
export interface AskDone {
conversationId: string;
messageId: string;
suggestedFollowUps: string[];
remainingQuota: number;
usedChunks: UsedChunk[];
}
export interface ConversationMessage {
messageId: string;
role: 'user' | 'assistant' | string;
text: string;
}
export interface NotificationItem {
id: number;
type: string;
title: string;
body: string;
isRead: boolean;
createdAt: string;
}
export interface Plan {
id: string;
title: string;
/** تومان (currency = "IRT") */
price: number;
currency: string;
popular: boolean;
discountPercent: number;
durationDays: number;
features: string[];
badge: string;
}
export interface SubscriptionStatus {
active: boolean;
planId: string | null;
expiresAt: string | null;
}
export interface PurchaseResponse {
orderId: string;
paymentUrl: string;
}
export interface RedeemResponse {
success: boolean;
discountAmount: number;
/** "paid" یعنی کد کلِ مبلغ را پوشاند و نباید به درگاه رفت. */
orderStatus: string;
amount: number;
}
export interface OrderStatus {
orderId: string;
status: string;
amount: number;
}
export interface Ticket {
ticketId: string;
subject: string;
status: string;
messages: { id: string; sender: string; text: string }[];
}
export interface FamilyLink {
familyLinkId: string;
status: string;
childName: string;
}
export interface FamilyWeeklyReport {
weekStart: string;
xpEarned: number;
lessonsCompleted: number;
islandsCompleted: number;
currentStreak: number;
}
export interface FamilySettings {
dailyLimitMinutes: number;
quietHoursStart: string;
quietHoursEnd: string;
}
export interface AffiliateMe {
referralCode: string;
commissionRate: number;
referredCount: number;
paidCount: number;
totalCommission: number;
}

File diff suppressed because it is too large Load Diff

149
src/app/MainApp.tsx Normal file
View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { CheckCircle2, XCircle } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api';
import { AppDataProvider, useAppData } from '@/state/AppData';
import { NavigatorProvider, useNav } from '@/nav/Navigator';
import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { PrimaryButton } from '@/components/ui';
import { AskScreen } from '@/screens/AskScreen';
import { CharactersScreen } from '@/screens/CharactersScreen';
import { FamilyScreen } from '@/screens/FamilyScreen';
import { HomeScreen } from '@/screens/HomeScreen';
import { InviteScreen } from '@/screens/InviteScreen';
import { LeaderboardScreen } from '@/screens/LeaderboardScreen';
import { LessonScreen } from '@/screens/LessonScreen';
import { LoginScreen } from '@/screens/LoginScreen';
import { MapScreen } from '@/screens/MapScreen';
import { NotificationsScreen } from '@/screens/NotificationsScreen';
import { ProfileScreen } from '@/screens/ProfileScreen';
import { QuizScreen } from '@/screens/QuizScreen';
import { StreakScreen } from '@/screens/StreakScreen';
import { SubscriptionScreen } from '@/screens/SubscriptionScreen';
import { SupportScreen } from '@/screens/SupportScreen';
import { UnitScreen } from '@/screens/UnitScreen';
import type { PaymentReturn } from './launchParams';
interface Props {
profile: UserProfile;
grades: Grade[];
payment: PaymentReturn | null;
onSignedOut: () => void;
onRegistered: (profile: UserProfile) => Promise<void>;
}
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, payment, onSignedOut, onRegistered }: Props) {
return (
<AppDataProvider initialProfile={profile} grades={grades}>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} onRegistered={onRegistered} />
{payment && <PaymentResult payment={payment} />}
</NavigatorProvider>
</AppDataProvider>
);
}
function Screens({ onSignedOut, onRegistered }: Pick<Props, 'onSignedOut' | 'onRegistered'>) {
const nav = useNav();
const { route } = nav;
const showNav = isTab(route.name) && nav.tab === route.name;
return (
<>
<div className="relative flex-1 overflow-hidden">
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={JSON.stringify(route)}
className="absolute inset-0 flex flex-col"
initial={{ opacity: 0, x: -18 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 18 }}
transition={{ duration: 0.18 }}
>
<RouteView route={route} onSignedOut={onSignedOut} onRegistered={onRegistered} />
</motion.div>
</AnimatePresence>
</div>
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
</>
);
}
function RouteView({ route, onSignedOut, onRegistered }: { route: Route } & Pick<Props, 'onSignedOut' | 'onRegistered'>) {
switch (route.name) {
case 'home': return <HomeScreen />;
case 'map': return <MapScreen />;
case 'ask': return <AskScreen lessonId={route.lessonId} questionId={route.questionId} prompt={route.prompt} />;
case 'subscription': return <SubscriptionScreen />;
case 'profile': return <ProfileScreen onSignedOut={onSignedOut} />;
case 'unit': return <UnitScreen unitId={route.unitId} />;
case 'lesson': return <LessonScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
case 'quiz': return <QuizScreen lessonId={route.lessonId} nodeId={route.nodeId} />;
case 'notifications': return <NotificationsScreen />;
case 'leaderboard': return <LeaderboardScreen />;
case 'characters': return <CharactersScreen />;
case 'streak': return <StreakScreen />;
case 'support': return <SupportScreen />;
case 'family': return <FamilyScreen />;
case 'invite': return <InviteScreen />;
case 'register': return <RegisterView onRegistered={onRegistered} />;
}
}
/** مهمانی که شماره ثبت می‌کند؛ همان کاربر می‌ماند و فقط پروفایل تازه می‌شود. */
function RegisterView({ onRegistered }: Pick<Props, 'onRegistered'>) {
const nav = useNav();
const { setProfile } = useAppData();
const toast = useToast();
return (
<LoginScreen
upgrade
onCancel={nav.back}
onDone={async () => {
try {
const profile = await api.profile.get();
setProfile(profile);
await onRegistered(profile);
} catch {
/* ورود انجام شده؛ پروفایل با بازگشت به اپ تازه می‌شود */
}
toast('شماره‌ت ثبت شد! پیشرفتت امنه.', 'success');
nav.back();
}}
/>
);
}
/** نتیجه‌ی برگشت از درگاه؛ وضعیتِ سفارش از خودِ سرور هم دوباره خوانده می‌شود. */
function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true);
const [paid, setPaid] = useState(payment.status === 'paid');
useEffect(() => {
api.subscription
.order(payment.orderId)
.then((order) => setPaid(order.status === 'paid'))
.catch(() => {});
void refreshSummary();
}, [payment.orderId, refreshSummary]);
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<div className="flex flex-col items-center gap-3 pb-2 text-center">
{paid ? <CheckCircle2 size={52} className="text-mint" /> : <XCircle size={52} className="text-red-400" />}
<h2 className="text-lg font-black text-ink">{paid ? 'پرداخت موفق بود! 🎉' : 'پرداخت انجام نشد'}</h2>
<p className="text-sm leading-7 text-muted">
{paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طیِ چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
</div>
</Sheet>
);
}

View File

@@ -1,27 +0,0 @@
import React, { useState } from 'react'
const ERROR_IMG_SRC =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
export function ImageWithFallback(props: React.ImgHTMLAttributes<HTMLImageElement>) {
const [didError, setDidError] = useState(false)
const handleError = () => {
setDidError(true)
}
const { src, alt, style, className, ...rest } = props
return didError ? (
<div
className={`inline-block bg-gray-100 text-center align-middle ${className ?? ''}`}
style={style}
>
<div className="flex items-center justify-center w-full h-full">
<img src={ERROR_IMG_SRC} alt="Error loading image" {...rest} data-original-url={src} />
</div>
</div>
) : (
<img src={src} alt={alt} className={className} style={style} {...rest} onError={handleError} />
)
}

View File

@@ -1,66 +0,0 @@
"use client";
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "./utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -1,157 +0,0 @@
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -1,66 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,11 +0,0 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@@ -1,53 +0,0 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "./utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className,
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -1,46 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -1,109 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "./utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className,
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -1,58 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9 rounded-md",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -1,75 +0,0 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "./utils";
import { buttonVariants } from "./button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md",
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100",
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export { Calendar };

View File

@@ -1,92 +0,0 @@
import * as React from "react";
import { cn } from "./utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 pt-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<h4
data-slot="card-title"
className={cn("leading-none", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<p
data-slot="card-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 [&:last-child]:pb-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 pb-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -1,241 +0,0 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "./utils";
import { Button } from "./button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -1,353 +0,0 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "./utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -1,32 +0,0 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "./utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border bg-input-background dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -1,33 +0,0 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -1,177 +0,0 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "./utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -1,252 +0,0 @@
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -1,135 +0,0 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "./utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -1,132 +0,0 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "./utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -1,257 +0,0 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -1,168 +0,0 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "./utils";
import { Label } from "./label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -1,44 +0,0 @@
"use client";
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "./utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -1,77 +0,0 @@
"use client";
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "./utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName,
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center gap-1", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm bg-input-background transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "./utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base bg-input-background transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -1,24 +0,0 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "./utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -1,276 +0,0 @@
"use client";
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "./utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className,
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View File

@@ -1,168 +0,0 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "./utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className,
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center",
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@@ -1,127 +0,0 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "./utils";
import { Button, buttonVariants } from "./button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -1,48 +0,0 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "./utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -1,31 +0,0 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "./utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -1,45 +0,0 @@
"use client";
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "./utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -1,56 +0,0 @@
"use client";
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "./utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -1,58 +0,0 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "./utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -1,189 +0,0 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "lucide-react";
import { cn } from "./utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -1,28 +0,0 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "./utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -1,139 +0,0 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "./utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -1,726 +0,0 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "./use-mobile";
import { cn } from "./utils";
import { Button } from "./button";
import { Input } from "./input";
import { Separator } from "./separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "./sheet";
import { Skeleton } from "./skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open],
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className,
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className,
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@@ -1,13 +0,0 @@
import { cn } from "./utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -1,63 +0,0 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "./utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-4 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -1,25 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -1,31 +0,0 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "./utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-switch-background focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-card dark:data-[state=unchecked]:bg-card-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -1,116 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "./utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -1,66 +0,0 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "./utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-xl p-[3px] flex",
className,
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-card dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -1,18 +0,0 @@
import * as React from "react";
import { cn } from "./utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -1,73 +0,0 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
import { toggleVariants } from "./toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@@ -1,47 +0,0 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "./utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@@ -1,61 +0,0 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "./utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -1,21 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

29
src/app/launchParams.ts Normal file
View File

@@ -0,0 +1,29 @@
import { KEYS, store } from '@/lib/storage';
/** برگشت از درگاهِ پرداخت: سرور کاربر را به `/?orderId=…&status=…` برمی‌گرداند. */
export interface PaymentReturn {
orderId: string;
status: string;
}
/**
* پارامترهای آدرسِ ورود را یک‌بار می‌خواند و از نوارِ آدرس پاک می‌کند، تا با
* رفرش دوباره پیامِ پرداخت نشان داده نشود:
* ?ref=CODE کدِ معرف (لینکِ دعوت)
* ?orderId=…&status=… نتیجه‌ی پرداخت
*/
function readLaunchParams(): PaymentReturn | null {
const params = new URLSearchParams(window.location.search);
const ref = params.get('ref');
const orderId = params.get('orderId');
const status = params.get('status');
if (ref) store.set(KEYS.referralCode, ref.slice(0, 40));
if (ref || orderId) {
window.history.replaceState(null, '', window.location.pathname);
}
return orderId ? { orderId, status: status ?? '' } : null;
}
/** یک‌بار، هنگامِ بار شدنِ اپ. */
export const launchPayment = readLaunchParams();

View File

@@ -0,0 +1,66 @@
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>
);
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
/**
* قابِ اپ. روی گوشی تمام‌صفحه است؛ روی صفحه‌ی بزرگ (دسکتاپ) وسطِ صفحه با
* عرضِ گوشی نشان داده می‌شود تا چیدمانِ موبایلی به هم نریزد.
*
* قبلاً یک «قابِ گوشی» ثابتِ ۳۹۰×۸۴۴ با ساعتِ ساختگی بود که روی گوشیِ واقعی
* داخلِ صفحه یک گوشیِ نقاشی‌شده نشان می‌داد.
*/
export function AppShell({ children }: { children: ReactNode }) {
return (
<div className="flex h-[100dvh] w-full items-stretch justify-center sm:items-center sm:py-6">
<div
dir="rtl"
className="relative flex h-full w-full max-w-[440px] flex-col overflow-hidden bg-page sm:h-[min(880px,100%)] sm:rounded-[36px] sm:shadow-2xl sm:ring-8 sm:ring-[#22223a]"
>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { Brain, Crown, Home, Map, UserRound, type LucideIcon } from 'lucide-react';
import type { TabName } from '@/nav/routes';
const ITEMS: { id: TabName; icon: LucideIcon; label: string }[] = [
{ id: 'home', icon: Home, label: 'خانه' },
{ id: 'map', icon: Map, label: 'نقشه' },
{ id: 'ask', icon: Brain, label: 'دستیار' },
{ id: 'subscription', icon: Crown, label: 'اشتراک' },
{ id: 'profile', icon: UserRound, label: 'من' },
];
export function BottomNav({ active, onChange }: { active: TabName | null; onChange: (tab: TabName) => void }) {
return (
<nav className="flex flex-shrink-0 items-center justify-around border-t border-line bg-white px-2 pb-safe pt-1.5">
{ITEMS.map(({ id, icon: Icon, label }) => {
const on = active === id;
return (
<button
key={id}
type="button"
onClick={() => onChange(id)}
aria-current={on ? 'page' : undefined}
className={`flex flex-col items-center gap-0.5 rounded-xl px-2 pb-1.5 transition-colors ${on ? 'text-coral' : 'text-muted'}`}
>
<span className={`rounded-xl p-1.5 transition-colors ${on ? 'bg-[#fde9e5]' : ''}`}>
<Icon size={20} strokeWidth={on ? 2.5 : 1.9} />
</span>
<span className="text-[10px] font-bold">{label}</span>
</button>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,18 @@
import {
BookOpen, Brain, Compass, Grid3x3, LineChart, Play, Repeat, Sigma, Sparkles, TrendingUp,
type LucideIcon,
} from 'lucide-react';
/**
* آیکنِ جزیره. سرور نامِ آیکنِ lucide را می‌دهد (مثلاً "Grid3x3")؛ فقط این
* مجموعه‌ی کوچک در باندل است تا کلِ کتابخانه‌ی آیکن بار نشود.
*/
const ICONS: Record<string, LucideIcon> = {
Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain, Play,
};
const FALLBACK: LucideIcon[] = [Grid3x3, TrendingUp, LineChart, Sigma, Compass, Repeat, BookOpen, Sparkles, Brain];
export function iconFor(name: string | undefined, index = 0): LucideIcon {
return (name && ICONS[name]) || FALLBACK[index % FALLBACK.length];
}

60
src/components/Sheet.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { useEffect, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { X } from 'lucide-react';
/** برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه یا Escape بسته می‌شود. */
export function Sheet({
open,
onClose,
title,
children,
}: {
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
return (
<AnimatePresence>
{open && (
<div className="absolute inset-0 z-50 flex flex-col justify-end">
<motion.div
className="absolute inset-0 bg-[#14142a]/45"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
<motion.div
role="dialog"
aria-modal="true"
className="relative max-h-[85%] overflow-y-auto rounded-t-[28px] bg-white px-5 pt-3"
style={{ paddingBottom: 'calc(2rem + env(safe-area-inset-bottom))' }}
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
>
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
{title && (
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="text-base font-black text-ink">{title}</h2>
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<X size={16} />
</button>
</div>
)}
{children}
</motion.div>
</div>
)}
</AnimatePresence>
);
}

45
src/components/Toast.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
type Tone = 'info' | 'success' | 'error';
interface ToastItem { id: number; text: string; tone: Tone }
const ToastContext = createContext<(text: string, tone?: Tone) => void>(() => {});
/** پیامِ کوتاهِ بالای صفحه که خودش بعد از چند ثانیه می‌رود. */
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const show = useCallback((text: string, tone: Tone = 'info') => {
const id = nextId.current++;
setItems((list) => [...list.slice(-2), { id, text, tone }]);
setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), 3500);
}, []);
return (
<ToastContext.Provider value={show}>
{children}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[60] flex flex-col items-center gap-2 px-4 pt-safe">
<AnimatePresence>
{items.map((t) => (
<motion.div
key={t.id}
role="status"
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -16 }}
className={`mt-3 max-w-full rounded-2xl px-4 py-2.5 text-center text-xs font-bold leading-6 shadow-lg ${
t.tone === 'error' ? 'bg-red-500 text-white' : t.tone === 'success' ? 'bg-mint text-white' : 'bg-ink text-white'
}`}
>
{t.text}
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}
export const useToast = () => useContext(ToastContext);

View File

@@ -0,0 +1,34 @@
/** مسیرِ منحنیِ بینِ خانه‌های نقشه (مختصاتِ x به درصد، y به پیکسل). */
export interface Point { x: number; y: number }
export function zigzagPositions(count: number, spacingY: number, startY = 80): Point[] {
const xs = [50, 74, 46, 26, 54, 76, 48, 28];
return Array.from({ length: count }, (_, i) => ({ x: xs[i % xs.length], y: startY + i * spacingY }));
}
export function ZigzagPath({
points,
height,
colorOf,
}: {
points: Point[];
height: number;
/** رنگِ تکه‌ی i (از نقطه‌ی i به i+1)؛ null یعنی خاکستری. */
colorOf: (i: number) => string | null;
}) {
const segments = points.slice(0, -1).map((p1, i) => {
const p2 = points[i + 1];
const midY = (p1.y + p2.y) / 2;
return { d: `M ${p1.x} ${p1.y} C ${p1.x} ${midY}, ${p2.x} ${midY}, ${p2.x} ${p2.y}`, color: colorOf(i) };
});
return (
<svg className="pointer-events-none absolute inset-0" width="100%" height={height} viewBox={`0 0 100 ${height}`} preserveAspectRatio="none" aria-hidden>
{segments.map((s, i) => (
<path key={`s${i}`} d={s.d} fill="none" stroke="#d7d5e8" strokeWidth="10" strokeLinecap="round" opacity="0.4" vectorEffect="non-scaling-stroke" />
))}
{segments.map((s, i) => (
<path key={`p${i}`} d={s.d} fill="none" stroke={s.color ?? '#dedced'} strokeWidth="7" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
))}
</svg>
);
}

198
src/components/ui.tsx Normal file
View File

@@ -0,0 +1,198 @@
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
import { ChevronRight, RotateCw } from 'lucide-react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
import { friendlyError } from '@/lib/errors';
import { useNav } from '@/nav/Navigator';
/* ─── سربرگِ رنگیِ صفحه ──────────────────────────────────────────────────── */
export function ScreenHeader({
title,
subtitle,
color,
back = false,
mascot,
children,
}: {
title: ReactNode;
subtitle?: ReactNode;
color: string;
/** دکمه‌ی بازگشت؛ true یعنی صفحه‌ی قبل در پشته. */
back?: boolean | (() => void);
mascot?: { char: SeyarChar; mood?: SeyarMood; size?: number };
children?: ReactNode;
}) {
const nav = useNav();
const onBack = typeof back === 'function' ? back : nav.back;
return (
<header
className="relative flex-shrink-0 overflow-hidden rounded-b-[32px] px-5 pb-6 pt-safe text-white"
style={{ backgroundColor: color }}
>
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="pointer-events-none absolute -right-10 top-10 h-28 w-28 rounded-full bg-white/10" />
<div className="relative z-10 pt-6">
{back && (
<IconButton label="بازگشت" onClick={onBack} className="mb-3 bg-white/15">
<ChevronRight size={18} strokeWidth={2.5} />
</IconButton>
)}
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<h1 className="text-xl font-black leading-snug">{title}</h1>
{subtitle && <div className="mt-1 text-xs text-white/75">{subtitle}</div>}
</div>
{mascot && <SeyarMascot char={mascot.char} mood={mascot.mood ?? 'happy'} size={mascot.size ?? 64} />}
</div>
{children}
</div>
</header>
);
}
/* ─── دکمه‌ها ──────────────────────────────────────────────────────────────── */
export function IconButton({
label,
className = '',
children,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return (
<button
type="button"
aria-label={label}
title={label}
className={`flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full transition-transform active:scale-90 ${className}`}
{...rest}
>
{children}
</button>
);
}
export function PrimaryButton({
color = '#ED6956',
loading = false,
className = '',
style,
children,
disabled,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { color?: string; loading?: boolean }) {
return (
<button
type="button"
disabled={disabled || loading}
className={`flex w-full items-center justify-center gap-2 rounded-2xl py-4 text-base font-black text-white transition-transform active:scale-[0.98] disabled:opacity-50 disabled:active:scale-100 ${className}`}
style={{ backgroundColor: color, boxShadow: `0 10px 24px -8px ${color}88`, ...style }}
{...rest}
>
{loading ? <Spinner /> : children}
</button>
);
}
export function SecondaryButton({ className = '', children, ...rest }: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={`flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-line bg-white py-3.5 text-sm font-bold text-body transition-transform active:scale-[0.98] disabled:opacity-50 ${className}`}
{...rest}
>
{children}
</button>
);
}
export function Spinner({ size = 20, className = '' }: { size?: number; className?: string }) {
return (
<span
role="status"
aria-label="در حالِ بارگذاری"
className={`inline-block animate-spin rounded-full border-2 border-current border-t-transparent ${className}`}
style={{ width: size, height: size }}
/>
);
}
/* ─── حالت‌های بارگذاری / خطا / خالی ───────────────────────────────────────── */
function StateBox({ char, mood, text, children }: { char: SeyarChar; mood: SeyarMood; text: ReactNode; children?: ReactNode }) {
return (
<div className="flex flex-col items-center justify-center gap-3 px-8 py-16 text-center">
<SeyarMascot char={char} mood={mood} size={72} />
<p className="text-sm leading-7 text-muted">{text}</p>
{children}
</div>
);
}
export function LoadingState({ text = 'یه لحظه صبر کن…', char = 'sigma' as SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />;
}
export function ErrorState({ error, onRetry, text }: { error?: unknown; onRetry?: () => void; text?: string }) {
return (
<StateBox char="pi" mood="sad" text={text ?? friendlyError(error)}>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="flex items-center gap-1.5 rounded-full bg-white px-4 py-2 text-xs font-bold text-coral shadow-sm ring-1 ring-line"
>
<RotateCw size={13} /> دوباره امتحان کن
</button>
)}
</StateBox>
);
}
export function EmptyState({ text, char = 'pi' as SeyarChar }: { text: string; char?: SeyarChar }) {
return <StateBox char={char} mood="idle" text={text} />;
}
export function InlineError({ children }: { children: ReactNode }) {
return (
<div role="alert" className="rounded-xl border border-red-200 bg-red-50 px-3 py-2.5 text-xs leading-6 text-red-600">
{children}
</div>
);
}
/* ─── چیزهای کوچک ──────────────────────────────────────────────────────────── */
export function ProgressBar({ percent, color, track = '#eae7ff', height = 8 }: { percent: number; color: string; track?: string; height?: number }) {
return (
<div className="w-full overflow-hidden rounded-full" style={{ height, backgroundColor: track }}>
<div
className="h-full rounded-full transition-[width] duration-700 ease-out"
style={{ width: `${Math.max(0, Math.min(100, percent))}%`, backgroundColor: color }}
/>
</div>
);
}
export function Card({ className = '', style, children, onClick }: { className?: string; style?: CSSProperties; children: ReactNode; onClick?: () => void }) {
const Tag = onClick ? 'button' : 'div';
return (
<Tag
type={onClick ? 'button' : undefined}
onClick={onClick}
style={style}
className={`rounded-3xl border border-line bg-white p-4 text-right shadow-sm ${onClick ? 'transition-transform active:scale-[0.98]' : ''} ${className}`}
>
{children}
</Tag>
);
}
/** بدنه‌ی اسکرول‌شونده‌ی صفحه، زیرِ سربرگ. */
export function ScreenBody({ className = '', children }: { className?: string; children: ReactNode }) {
return <div className={`flex-1 overflow-y-auto px-4 py-5 ${className}`}>{children}</div>;
}
export function Screen({ className = 'bg-page', children }: { className?: string; children: ReactNode }) {
return <div className={`flex h-full flex-col ${className}`}>{children}</div>;
}

13
src/config.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* آدرسِ بک‌اند از کجا می‌آید، به ترتیبِ اولویت:
* ۱. public/config.js — در زمانِ اجرا، بدونِ build دوباره قابلِ عوض کردن
* ۲. VITE_API_BASE_URL — در زمانِ build (.env.production / .env.development)
* ۳. "/api" — هم‌دامنه، وقتی nginx مسیرِ /api را پراکسی می‌کند
*
* هیچ دامنه‌ای در کد نوشته نشده؛ جابه‌جایی دامنه فقط تنظیمات است.
*/
export function resolveApiBaseUrl(): string {
const runtime = window.__TADRISINO_CONFIG__?.apiBaseUrl?.trim();
const buildTime = import.meta.env.VITE_API_BASE_URL?.trim();
return (runtime || buildTime || '/api').replace(/\/+$/, '');
}

11
src/lib/errors.ts Normal file
View File

@@ -0,0 +1,11 @@
import { ApiError } from '@/api';
/** پیامِ قابلِ فهم برای دانش‌آموز، از هر خطایی. */
export function friendlyError(err: unknown, fallback = 'یه مشکلی پیش اومد، دوباره امتحان کن'): string {
if (!(err instanceof ApiError)) return fallback;
if (err.isNetwork) return 'ارتباط با سرور برقرار نشد؛ اینترنتت رو چک کن';
if (err.isRateLimited) return err.detail && !err.detail.startsWith('خطای') ? err.detail : 'زیادی تلاش کردی؛ چند لحظه صبر کن';
if (err.isProviderDown) return 'این سرویس الان در دسترس نیست؛ کمی بعد دوباره امتحان کن';
if (err.status >= 500) return 'سرور موقتاً مشکل داره؛ کمی بعد دوباره امتحان کن';
return err.detail || fallback;
}

70
src/lib/format.ts Normal file
View File

@@ -0,0 +1,70 @@
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
/** ارقامِ لاتین را فارسی می‌کند؛ برای نمایش. */
export function toFa(value: number | string): string {
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)]);
}
/**
* ارقامِ فارسی و عربی را لاتین می‌کند؛ برای ورودی.
* کیبوردِ فارسیِ گوشی «۰۹۱۲» تایپ می‌کند و `\d` در JS آن را نمی‌شناسد.
*/
export function toEnDigits(value: string): string {
return value
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660));
}
/** فقط رقم‌ها، با پشتیبانی از ارقامِ فارسی. */
export function digitsOnly(value: string, maxLength?: number): string {
const digits = toEnDigits(value).replace(/\D/g, '');
return maxLength ? digits.slice(0, maxLength) : digits;
}
/** ۱۲۳۴۵۶ → «۱۲۳٬۴۵۶» */
export function formatNumber(value: number): string {
return toFa(Math.round(value).toLocaleString('en-US')).replace(/,/g, '٬');
}
export function formatToman(value: number): string {
return `${formatNumber(value)} تومان`;
}
/** «۵ دقیقه پیش»، «دیروز»، … */
export function relativeTime(iso: string, now = Date.now()): string {
const then = Date.parse(iso);
if (Number.isNaN(then)) return '';
const minutes = Math.round((now - then) / 60_000);
if (minutes < 1) return 'همین الان';
if (minutes < 60) return `${toFa(minutes)} دقیقه پیش`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${toFa(hours)} ساعت پیش`;
const days = Math.round(hours / 24);
if (days === 1) return 'دیروز';
if (days < 30) return `${toFa(days)} روز پیش`;
return formatJalaliDate(iso);
}
/** «تا ۲ ساعت و ۱۵ دقیقه‌ی دیگر» برای زمانِ پر شدنِ قلب. */
export function timeUntil(iso: string, now = Date.now()): string {
const ms = Date.parse(iso) - now;
if (Number.isNaN(ms) || ms <= 0) return 'همین حالا';
const totalMinutes = Math.ceil(ms / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours && minutes) return `${toFa(hours)} ساعت و ${toFa(minutes)} دقیقه`;
if (hours) return `${toFa(hours)} ساعت`;
return `${toFa(minutes)} دقیقه`;
}
export function formatJalaliDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric', month: 'long', year: 'numeric' }).format(date);
}
/** سالِ شمسیِ امروز؛ سرور birthYear را شمسی می‌خواهد. */
export function currentJalaliYear(date = new Date()): number {
const year = new Intl.DateTimeFormat('en-US-u-ca-persian', { year: 'numeric' }).format(date);
return parseInt(toEnDigits(year), 10);
}

54
src/lib/sound.ts Normal file
View File

@@ -0,0 +1,54 @@
import { KEYS, store } from './storage';
/**
* صداهای کوتاهِ رابط. یک AudioContext برای کلِ اپ؛ ساختنِ یکی برای هر صدا
* در Safari بعد از چند بار شکست می‌خورد.
*/
let ctx: AudioContext | null = null;
function context(): AudioContext | null {
if (ctx) return ctx;
const Ctor = window.AudioContext ?? window.webkitAudioContext;
if (!Ctor) return null;
try {
ctx = new Ctor();
} catch {
return null;
}
return ctx;
}
export function soundEnabled(): boolean {
return store.get(KEYS.sound) !== 'off';
}
export function setSoundEnabled(on: boolean) {
store.set(KEYS.sound, on ? 'on' : 'off');
}
function tone(frequency: number, start: number, duration: number, volume = 0.05) {
const audio = context();
if (!audio || !soundEnabled()) return;
if (audio.state === 'suspended') void audio.resume();
const t = audio.currentTime + start;
const osc = audio.createOscillator();
const gain = audio.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency, t);
gain.gain.setValueAtTime(0.0001, t);
gain.gain.exponentialRampToValueAtTime(volume, t + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
osc.connect(gain).connect(audio.destination);
osc.start(t);
osc.stop(t + duration + 0.02);
}
export const sounds = {
tick: () => tone(720, 0, 0.045, 0.04),
correct: () => {
tone(660, 0, 0.12);
tone(990, 0.1, 0.18);
},
wrong: () => tone(220, 0, 0.25, 0.06),
finish: () => [523, 659, 784, 1047].forEach((f, i) => tone(f, i * 0.11, 0.2)),
};

45
src/lib/storage.ts Normal file
View File

@@ -0,0 +1,45 @@
/** localStorage با try/catch؛ در حالتِ خصوصیِ مرورگر یا دسترسیِ مسدود throw نمی‌کند. */
export const store = {
get(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
},
set(key: string, value: string) {
try {
localStorage.setItem(key, value);
} catch {
/* نادیده */
}
},
remove(key: string) {
try {
localStorage.removeItem(key);
} catch {
/* نادیده */
}
},
};
export const KEYS = {
deviceId: 'deviceId',
onboarded: 'tadrisino.onboarded',
conversationId: 'tadrisino.conversationId',
ticketIds: 'tadrisino.ticketIds',
sound: 'tadrisino.sound',
referralCode: 'tadrisino.referralCode',
} as const;
/** شناسه‌ی ثابتِ این مرورگر برای ورودِ مهمان. */
export function getDeviceId(): string {
let id = store.get(KEYS.deviceId);
if (!id) {
id = globalThis.crypto && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;
store.set(KEYS.deviceId, id);
}
return id;
}

46
src/lib/useAsync.ts Normal file
View File

@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState, type DependencyList } from 'react';
export interface AsyncState<T> {
data: T | undefined;
error: unknown;
loading: boolean;
reload: () => void;
setData: (update: T | ((prev: T | undefined) => T | undefined)) => void;
}
/**
* داده‌ای که از سرور می‌آید، با حالت‌های بارگذاری/خطا و «دوباره».
* پاسخِ درخواستِ کهنه (بعد از عوض شدنِ deps) نادیده گرفته می‌شود.
*/
export function useAsync<T>(load: () => Promise<T>, deps: DependencyList): AsyncState<T> {
const [data, setData] = useState<T | undefined>(undefined);
const [error, setError] = useState<unknown>(null);
const [loading, setLoading] = useState(true);
const [nonce, setNonce] = useState(0);
const loadRef = useRef(load);
loadRef.current = load;
useEffect(() => {
let alive = true;
setLoading(true);
setError(null);
loadRef.current().then(
(value) => {
if (!alive) return;
setData(value);
setLoading(false);
},
(err: unknown) => {
if (!alive) return;
setError(err);
setLoading(false);
},
);
return () => {
alive = false;
};
}, [...deps, nonce]);
const reload = useCallback(() => setNonce((n) => n + 1), []);
return { data, error, loading, reload, setData };
}

View File

@@ -1,7 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './app/App';
import './styles/index.css';
import { createRoot } from "react-dom/client";
import App from "./app/App.tsx";
import "./styles/index.css";
createRoot(document.getElementById("root")!).render(<App />);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,36 @@
import { memo, useMemo } from 'react';
import { motion } from 'motion/react';
import { SEYAR_INFO, SEYAR_SVG, type SeyarChar, type SeyarMood } from './seyar';
interface Props {
char: SeyarChar;
mood?: SeyarMood;
size?: number;
/** بالا و پایین رفتنِ آرام، برای ماسکوتی که منتظر است. */
bob?: boolean;
className?: string;
}
function SeyarMascotBase({ char, mood = 'idle', size = 100, bob = false, className }: Props) {
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
return (
<motion.div
key={char + mood}
role="img"
aria-label={SEYAR_INFO[char].name}
className={className}
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1, y: bob ? [0, -6, 0] : 0 }}
transition={
bob
? { y: { duration: 1.8, repeat: Infinity, ease: 'easeInOut' }, scale: { type: 'spring', stiffness: 280, damping: 18 } }
: { type: 'spring', stiffness: 280, damping: 18 }
}
style={{ width: size, height: size, lineHeight: 0, flexShrink: 0 }}
// SVG از seyar.ts ساخته می‌شود و هیچ ورودیِ کاربر در آن نیست.
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
}
export const SeyarMascot = memo(SeyarMascotBase);

150
src/mascots/seyar.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* سه‌یار: سیگما (Σ)، دلتا (Δ) و پی (π) — ماسکوت‌های اپ.
*
* هر سه از دلِ یک دفترچه‌ی بی‌نهایت آمده‌اند و هر کدام از جنسِ یک نمادِ ریاضی‌اند:
* سیگما → راهنمای درس و آزمون دلتا → هم‌تیمیِ تمرینِ سریع پی → دستیار و همراهِ دلگرمی
*
* خروجی رشته‌ی SVG است (نه JSX) تا هر سه با یک موتورِ چهره ساخته شوند.
* ورودی‌ها همه از همین فایل‌اند و هیچ متنِ کاربر واردِ SVG نمی‌شود.
*/
export type SeyarChar = 'sigma' | 'delta' | 'pi';
export type SeyarMood = 'idle' | 'happy' | 'excited' | 'sad' | 'angry' | 'depressed';
const PALETTE: Record<SeyarChar, Record<string, string>> = {
sigma: { body: "#4C5FD5", inner: "#7A8CFF", belly: "#EAF0FF", ink: "#232355", cheek: "#FFB2C0", mouth: "#FF6B6B", tear: "#8EC9FF", badge: "#FFD23F" },
delta: { body: "#FF7A3D", inner: "#FFB25E", belly: "#FFE7C7", ink: "#6E2A12", cheek: "#FF8FA3", mouth: "#D6402A", tear: "#8EC9FF", spike: "#FFEB3B" },
pi: { body: "#9B7BFF", inner: "#C6B3FF", belly: "#F1ECFF", ink: "#3B2B66", cheek: "#FFC2D1", mouth: "#FF6F91", tear: "#5EC8E8" },
};
const Y_SHIFT: Record<SeyarMood, number> = { idle: 0, happy: 0, excited: -4, sad: 3, angry: 0, depressed: 9 };
const ARM_DELTA: Record<SeyarMood, number> = { idle: 0, happy: 2, excited: 46, sad: -4, angry: -8, depressed: -10 };
function face(mood: SeyarMood, ex: number, ey: number, mx: number, my: number, ink: string, mouthC: string, tearC: string, cheekC: string, spread: number) {
const lx = ex, rx = ex + spread;
let eyes = "", mouth = "", cheeks = "", extra = "";
if (mood === "idle") {
eyes = `<circle cx="${lx}" cy="${ey}" r="9" fill="${ink}"/><circle cx="${rx}" cy="${ey}" r="9" fill="${ink}"/>`
+ `<circle cx="${lx + 3}" cy="${ey - 3}" r="3" fill="#fff"/><circle cx="${rx + 3}" cy="${ey - 3}" r="3" fill="#fff"/>`;
mouth = `<path d="M${mx - 14} ${my} q14 10 28 0" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
} else if (mood === "happy") {
eyes = `<path d="M${lx - 11} ${ey} q11 -14 22 0" fill="none" stroke="${ink}" stroke-width="5.5" stroke-linecap="round"/>`
+ `<path d="M${rx - 11} ${ey} q11 -14 22 0" fill="none" stroke="${ink}" stroke-width="5.5" stroke-linecap="round"/>`;
mouth = `<path d="M${mx - 20} ${my - 4} q20 26 40 0 q-20 10 -40 0z" fill="${mouthC}" stroke="${ink}" stroke-width="4.5" stroke-linejoin="round"/>`;
cheeks = `<ellipse cx="${lx - 14}" cy="${my - 14}" rx="11" ry="7" fill="${cheekC}"/><ellipse cx="${rx + 14}" cy="${my - 14}" rx="11" ry="7" fill="${cheekC}"/>`;
} else if (mood === "excited") {
eyes = `<circle cx="${lx}" cy="${ey}" r="11" fill="${ink}"/><circle cx="${rx}" cy="${ey}" r="11" fill="${ink}"/>`
+ `<circle cx="${lx + 4}" cy="${ey - 4}" r="4" fill="#fff"/><circle cx="${rx + 4}" cy="${ey - 4}" r="4" fill="#fff"/>`
+ `<path d="M${lx - 16} ${ey - 16} l3 6 l6 -2 l-4 5 l4 5 l-6 -2 l-3 6 l-1 -6 l-6 1 l5 -4 l-5 -4 l6 1z" fill="${tearC}" opacity=".85"/>`;
mouth = `<ellipse cx="${mx}" cy="${my + 2}" rx="16" ry="14" fill="${mouthC}" stroke="${ink}" stroke-width="4.5"/>`;
cheeks = `<ellipse cx="${lx - 14}" cy="${my - 12}" rx="12" ry="8" fill="${cheekC}"/><ellipse cx="${rx + 14}" cy="${my - 12}" rx="12" ry="8" fill="${cheekC}"/>`;
extra = `<g stroke="${tearC}" stroke-width="4" stroke-linecap="round"><path d="M${lx - 30} ${ey - 24} l-7 -10"/><path d="M${rx + 30} ${ey - 24} l7 -10"/></g>`;
} else if (mood === "sad") {
eyes = `<circle cx="${lx}" cy="${ey + 2}" r="8" fill="${ink}"/><circle cx="${rx}" cy="${ey + 2}" r="8" fill="${ink}"/>`
+ `<path d="M${lx - 10} ${ey - 10} q9 -6 18 -2" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`
+ `<path d="M${rx - 8} ${ey - 12} q9 -4 18 2" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
mouth = `<path d="M${mx - 16} ${my + 8} q16 -14 32 0" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
extra = `<path d="M${lx - 2} ${ey + 10} q-4 12 0 18 q4 -6 0 -18z" fill="${tearC}"/>`;
} else if (mood === "angry") {
eyes = `<path d="M${lx - 9} ${ey - 4} l14 4 l-4 8 z" fill="${ink}"/><path d="M${rx + 9} ${ey - 4} l-14 4 l4 8 z" fill="${ink}"/>`
+ `<path d="M${lx - 12} ${ey - 12} l16 4" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`
+ `<path d="M${rx + 12} ${ey - 12} l-16 4" stroke="${ink}" stroke-width="5" stroke-linecap="round"/>`;
mouth = `<path d="M${mx - 14} ${my + 4} l6 -6 l6 6 l6 -6 l6 6 l6 -6" fill="none" stroke="${ink}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>`;
cheeks = `<ellipse cx="${lx - 14}" cy="${my - 14}" rx="9" ry="6" fill="${mouthC}" opacity=".55"/><ellipse cx="${rx + 14}" cy="${my - 14}" rx="9" ry="6" fill="${mouthC}" opacity=".55"/>`;
extra = `<g stroke="${ink}" stroke-width="4" stroke-linecap="round" opacity=".8"><path d="M${lx - 34} ${ey - 30} q4 -8 -2 -14"/><path d="M${rx + 34} ${ey - 30} q-4 -8 2 -14"/></g>`;
} else if (mood === "depressed") {
eyes = `<path d="M${lx - 10} ${ey} q10 5 20 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round"/>`
+ `<path d="M${rx - 10} ${ey} q10 5 20 0" fill="none" stroke="${ink}" stroke-width="6" stroke-linecap="round"/>`;
mouth = `<path d="M${mx - 12} ${my} q12 6 24 1" fill="none" stroke="${ink}" stroke-width="4.5" stroke-linecap="round"/>`;
extra = `<g><ellipse cx="${mx}" cy="${ey - 58}" rx="26" ry="10" fill="#B9C2CC" opacity=".9"/>`
+ `<ellipse cx="${mx - 16}" cy="${ey - 52}" rx="16" ry="8" fill="#B9C2CC" opacity=".9"/>`
+ `<ellipse cx="${mx + 18}" cy="${ey - 52}" rx="15" ry="8" fill="#B9C2CC" opacity=".9"/>`
+ `<g stroke="#8FA0B3" stroke-width="3.5" stroke-linecap="round" opacity=".8">`
+ `<line x1="${mx - 14}" y1="${ey - 42}" x2="${mx - 18}" y2="${ey - 32}"/>`
+ `<line x1="${mx}" y1="${ey - 40}" x2="${mx - 4}" y2="${ey - 28}"/>`
+ `<line x1="${mx + 14}" y1="${ey - 42}" x2="${mx + 10}" y2="${ey - 30}"/></g></g>`;
}
return { eyes, mouth, cheeks, extra };
}
function seyarSigmaSVG(mood: SeyarMood): string {
const C = PALETTE.sigma;
const f = face(mood, 80, 86, 100, 114, C.ink, C.mouth, C.tear, C.cheek, 40);
const a = 12 + ARM_DELTA[mood], dy = Y_SHIFT[mood];
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
<line x1="100" y1="18" x2="100" y2="-8" stroke="${C.ink}" stroke-width="6" stroke-linecap="round"/>
<circle cx="100" cy="-14" r="9" fill="${C.badge}" stroke="${C.ink}" stroke-width="5"/>
<rect x="52" y="176" width="26" height="16" rx="8" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<rect x="122" y="176" width="26" height="16" rx="8" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<rect x="14" y="96" width="24" height="52" rx="12" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${a} 26 122)"/>
<rect x="162" y="96" width="24" height="52" rx="12" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${-a} 174 122)"/>
<rect x="20" y="70" width="14" height="34" rx="7" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<rect x="166" y="70" width="14" height="34" rx="7" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<rect x="34" y="24" width="132" height="150" rx="38" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
<rect x="54" y="44" width="92" height="98" rx="24" fill="${C.belly}" stroke="${C.ink}" stroke-width="4"/>
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
<circle cx="100" cy="160" r="14" fill="${C.badge}" stroke="${C.ink}" stroke-width="4"/>
<text x="100" y="167" font-size="18" text-anchor="middle" font-family="Georgia,serif" font-weight="bold" fill="${C.ink}">&#931;</text>
</g></svg>`;
}
function seyarDeltaSVG(mood: SeyarMood): string {
const C = PALETTE.delta;
const f = face(mood, 84, 108, 100, 128, C.ink, C.mouth, C.tear, C.cheek, 32);
const a = 16 + ARM_DELTA[mood], dy = Y_SHIFT[mood];
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
<ellipse cx="76" cy="182" rx="18" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<ellipse cx="124" cy="182" rx="18" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<rect x="18" y="106" width="22" height="46" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${a} 29 129)"/>
<rect x="160" y="106" width="22" height="46" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${-a} 171 129)"/>
<path d="M62 46 L48 16 L70 34 Z" fill="${C.spike}" stroke="${C.ink}" stroke-width="5" stroke-linejoin="round"/>
<path d="M138 46 L152 16 L130 34 Z" fill="${C.spike}" stroke="${C.ink}" stroke-width="5" stroke-linejoin="round"/>
<path d="M100 30 Q112 30 118 42 L160 156 Q166 172 150 174 L50 174 Q34 172 40 156 L82 42 Q88 30 100 30 Z" fill="${C.body}" stroke="${C.ink}" stroke-width="6" stroke-linejoin="round"/>
<path d="M100 74 Q108 74 112 84 L136 148 Q140 158 128 158 L72 158 Q60 158 64 148 L88 84 Q92 74 100 74 Z" fill="${C.belly}"/>
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
</g></svg>`;
}
function seyarPiSVG(mood: SeyarMood): string {
const C = PALETTE.pi;
const f = face(mood, 80, 112, 100, 138, C.ink, C.mouth, C.tear, C.cheek, 40);
const a = 18 + ARM_DELTA[mood], dy = Y_SHIFT[mood];
return `<svg viewBox="-14 -34 228 250" xmlns="http://www.w3.org/2000/svg"><g transform="translate(0 ${dy})">
<ellipse cx="76" cy="184" rx="19" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<ellipse cx="124" cy="184" rx="19" ry="12" fill="${C.inner}" stroke="${C.ink}" stroke-width="5"/>
<rect x="16" y="112" width="22" height="48" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${a} 27 136)"/>
<rect x="162" y="112" width="22" height="48" rx="11" fill="${C.body}" stroke="${C.ink}" stroke-width="5" transform="rotate(${-a} 173 136)"/>
<g stroke="${C.ink}" stroke-width="7" stroke-linecap="round">
<line x1="78" y1="22" x2="122" y2="22"/><line x1="86" y1="22" x2="84" y2="44"/><line x1="114" y1="22" x2="116" y2="44"/>
</g>
<ellipse cx="100" cy="118" rx="74" ry="72" fill="${C.body}" stroke="${C.ink}" stroke-width="6"/>
<ellipse cx="100" cy="134" rx="46" ry="46" fill="${C.belly}"/>
${f.extra}<g>${f.eyes}</g>${f.cheeks}${f.mouth}
</g></svg>`;
}
export const SEYAR_SVG: Record<SeyarChar, (mood: SeyarMood) => string> = {
sigma: seyarSigmaSVG, delta: seyarDeltaSVG, pi: seyarPiSVG,
};
export const SEYAR_INFO: Record<SeyarChar, { name: string; symbol: string; role: string }> = {
sigma: { name: "سیگما", symbol: "Σ", role: "راهنمای آزمون و کوییز" },
delta: { name: "دلتا", symbol: "Δ", role: "هم‌تیمیِ تمرین‌های سریع" },
pi: { name: "پی", symbol: "π", role: "همراهِ دلگرمی" },
};
/** slugِ کاراکترِ سرور (sigma/delta/pi) را به یکی از سه‌یار نگاشت می‌کند. */
export function seyarFromSlug(slug: string | null | undefined): SeyarChar | null {
const s = (slug ?? '').toLowerCase();
if (s === 'sigma' || s === 'delta' || s === 'pi') return s;
return null;
}
/** حالتِ ماسکوت که سرور می‌دهد («happy»، «encourage»، …) را به حالتِ سه‌یار نگاشت می‌کند. */
export function moodFromServer(server: string | null | undefined): SeyarMood {
const s = (server ?? '').toLowerCase();
if (s.includes('excited') || s.includes('celebrat')) return 'excited';
if (s.includes('happy') || s.includes('encourage')) return 'happy';
if (s.includes('sad')) return 'sad';
if (s.includes('angry')) return 'angry';
if (s.includes('depress') || s.includes('tired')) return 'depressed';
return 'idle';
}

72
src/nav/Navigator.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import type { Route, TabName } from './routes';
/**
* ناوبریِ پشته‌ای، هماهنگ با تاریخچه‌ی مرورگر.
*
* هر push یک ورودی به history اضافه می‌کند، پس دکمه‌ی back گوشی یا مرورگر
* یک صفحه عقب می‌رود به‌جای بیرون انداختنِ کاربر از اپ. تعویضِ تب پشته را
* از نو می‌سازد (مثلِ اپ‌های بومی).
*/
interface Nav {
route: Route;
/** تبی که ریشه‌ی پشته‌ی فعلی است؛ برای روشن کردنِ نوارِ پایین. */
tab: TabName | null;
canGoBack: boolean;
push: (route: Route) => void;
/** صفحه‌ی فعلی را عوض می‌کند بدونِ اضافه کردن به تاریخچه. */
replace: (route: Route) => void;
back: () => void;
switchTab: (tab: TabName) => void;
}
const NavContext = createContext<Nav | null>(null);
export function NavigatorProvider({ initial, children }: { initial: Route; children: ReactNode }) {
const [stack, setStack] = useState<Route[]>([initial]);
useEffect(() => {
const onPop = () => setStack((s) => (s.length > 1 ? s.slice(0, -1) : s));
window.addEventListener('popstate', onPop);
return () => window.removeEventListener('popstate', onPop);
}, []);
const push = useCallback((route: Route) => {
setStack((s) => [...s, route]);
window.history.pushState({ tadrisino: true }, '');
}, []);
const replace = useCallback((route: Route) => {
setStack((s) => [...s.slice(0, -1), route]);
}, []);
const back = useCallback(() => {
// popstate خودش صفحه را برمی‌دارد؛ پس دو منبعِ حقیقت نداریم.
window.history.back();
}, []);
const switchTab = useCallback((tab: TabName) => {
setStack([{ name: tab } as Route]);
}, []);
const value = useMemo<Nav>(() => {
const root = stack[0].name;
return {
route: stack[stack.length - 1],
tab: ['home', 'map', 'ask', 'subscription', 'profile'].includes(root) ? (root as TabName) : null,
canGoBack: stack.length > 1,
push,
replace,
back,
switchTab,
};
}, [stack, push, replace, back, switchTab]);
return <NavContext.Provider value={value}>{children}</NavContext.Provider>;
}
export function useNav(): Nav {
const nav = useContext(NavContext);
if (!nav) throw new Error('useNav بیرون از NavigatorProvider صدا زده شد');
return nav;
}

26
src/nav/routes.ts Normal file
View File

@@ -0,0 +1,26 @@
/** همه‌ی صفحه‌های اپ و پارامترهایشان. */
export type Route =
| { name: 'home' }
| { name: 'map' }
| { name: 'ask'; lessonId?: number; questionId?: string; prompt?: string }
| { name: 'subscription' }
| { name: 'profile' }
| { name: 'unit'; unitId: string }
| { name: 'lesson'; lessonId: number; nodeId?: string }
| { name: 'quiz'; lessonId: number; nodeId?: string }
| { name: 'notifications' }
| { name: 'leaderboard' }
| { name: 'characters' }
| { name: 'streak' }
| { name: 'support' }
| { name: 'family' }
| { name: 'invite' }
| { name: 'register' };
export type TabName = 'home' | 'map' | 'ask' | 'subscription' | 'profile';
export const TABS: TabName[] = ['home', 'map', 'ask', 'subscription', 'profile'];
export function isTab(name: Route['name']): name is TabName {
return (TABS as string[]).includes(name);
}

291
src/screens/AskScreen.tsx Normal file
View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react';
import { BookOpen, MessageSquarePlus, RotateCw, Send, ThumbsDown, ThumbsUp } from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
import { askStore, useAskState, type ChatMessage } from '@/state/askStore';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { useToast } from '@/components/Toast';
import { Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
const STARTERS = ['این فصل درباره‌ی چیه؟', 'یه مثالِ ساده برام حل کن', 'چطور برای امتحان تمرین کنم؟'];
/** «از پی بپرس»: دستیارِ ریاضی که از روی کتابِ پایه‌ی کاربر جواب می‌دهد. */
export function AskScreen({ lessonId, questionId, prompt }: { lessonId?: number; questionId?: string; prompt?: string }) {
const { profile } = useAppData();
const chat = useAskState();
const [input, setInput] = useState('');
const [busy, setBusy] = useState(false);
const [quota, setQuota] = useState<number | null>(null);
const listEnd = useRef<HTMLDivElement>(null);
const autoSent = useRef(false);
// درسِ تازه یعنی گفتگوی تازه؛ وگرنه اگر گفتگوی قبلی هست، تاریخچه‌اش را بیاور.
useEffect(() => {
if (lessonId !== undefined && lessonId !== chat.lessonId) {
askStore.reset(lessonId);
return;
}
if (chat.historyLoaded) return;
if (!chat.conversationId) {
askStore.markHistoryLoaded();
return;
}
api.ai
.history(chat.conversationId)
.then((r) =>
askStore.setHistory(
r.messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role as ChatMessage['role'], text: m.text, messageId: m.messageId })),
),
)
.catch(() => askStore.reset(null));
// فقط بارِ اولِ باز شدنِ صفحه؛ عمداً به chat وابسته نیست.
}, []);
useEffect(() => {
if (prompt && !autoSent.current) {
autoSent.current = true;
void send(prompt, questionId);
}
}, [prompt, questionId]);
// بعد از رندر اسکرول کن، تا پیشنهادهای ادامه که آخر از همه می‌آیند هم دیده شوند.
useEffect(() => {
const frame = requestAnimationFrame(() => listEnd.current?.scrollIntoView({ block: 'end' }));
return () => cancelAnimationFrame(frame);
}, [chat.messages]);
async function send(text: string, forQuestionId?: string) {
const question = text.trim();
if (!question || busy) return;
setBusy(true);
setInput('');
askStore.add({ role: 'user', text: question });
const replyId = askStore.add({ role: 'assistant', text: '' });
let answer = '';
try {
const done = await api.ai.ask(
{
userQuestion: question,
conversationId: askStore.get().conversationId ?? undefined,
lessonId: askStore.get().lessonId ?? undefined,
questionId: forQuestionId,
},
(delta) => {
answer += delta;
askStore.update(replyId, { text: answer });
},
);
askStore.update(replyId, {
text: answer,
messageId: done.messageId,
usedChunks: done.usedChunks,
followUps: done.suggestedFollowUps,
});
askStore.setConversation(done.conversationId);
setQuota(done.remainingQuota);
} catch (err) {
askStore.update(replyId, {
text: friendlyError(err, 'جوابی نرسید'),
failedQuestion: question,
});
} finally {
setBusy(false);
}
}
function retry(message: ChatMessage) {
if (!message.failedQuestion) return;
const all = askStore.get().messages;
const index = all.findIndex((m) => m.localId === message.localId);
const question = all[index - 1];
askStore.remove(message.localId);
if (question?.role === 'user') askStore.remove(question.localId);
void send(message.failedQuestion);
}
const empty = chat.messages.length === 0;
return (
<Screen>
<ScreenHeader
title="از پی بپرس"
subtitle={chat.lessonId ? 'از روی کتابِ همین درس جواب می‌دم' : 'دستیارِ ریاضی، از روی کتابِ خودت'}
color="#9B7BFF"
back={lessonId !== undefined || prompt !== undefined}
mascot={{ char: 'pi', mood: busy ? 'excited' : 'idle', size: 60 }}
>
{!empty && (
<button
type="button"
onClick={() => askStore.reset(null)}
disabled={busy}
className="mt-3 flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold disabled:opacity-50"
>
<MessageSquarePlus size={14} /> گفتگوی تازه
</button>
)}
</ScreenHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-4">
<Bubble role="assistant">
سلام {profile.name || 'دوستِ من'}! من پی‌ام 🙂 هر سوالی از ریاضیِ کتابت داری بپرس؛ قدم‌به‌قدم با هم حلش می‌کنیم.
</Bubble>
{empty && chat.historyLoaded && (
<div className="flex flex-wrap gap-2 pr-10">
{STARTERS.map((s) => (
<button key={s} type="button" onClick={() => void send(s)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
{s}
</button>
))}
</div>
)}
{chat.messages.map((m, i) => (
<Message key={m.localId} message={m} last={i === chat.messages.length - 1} busy={busy} onRetry={retry} onFollowUp={(q) => void send(q)} />
))}
<div ref={listEnd} />
</div>
<form
className="flex-shrink-0 border-t border-line bg-white px-4 py-3"
onSubmit={(e) => {
e.preventDefault();
void send(input);
}}
>
<div className="flex items-end gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value.slice(0, 1000))}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
void send(input);
}
}}
rows={1}
placeholder="سوالت رو بنویس…"
className="max-h-28 min-h-10 flex-1 resize-none rounded-3xl bg-soft px-4 py-2.5 text-sm leading-6 text-ink outline-none placeholder:text-faint"
/>
<button
type="submit"
aria-label="ارسال"
disabled={!input.trim() || busy}
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-violet text-white transition-opacity disabled:opacity-40"
>
<Send size={16} className="-scale-x-100" />
</button>
</div>
{quota !== null && <p className="mt-1.5 text-center text-[10px] text-muted">امروز {toFa(quota)} سوالِ دیگه می‌تونی بپرسی</p>}
</form>
</Screen>
);
}
function Bubble({ role, children }: { role: 'user' | 'assistant'; children: React.ReactNode }) {
const mine = role === 'user';
return (
<div className={`flex gap-2 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && (
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="happy" size={32} />
</span>
)}
<div
className={`max-w-[82%] whitespace-pre-line break-words rounded-2xl px-4 py-3 text-sm leading-[1.95] ${
mine ? 'rounded-tl-sm bg-coral text-white' : 'rounded-tr-sm border border-line bg-white text-ink shadow-sm'
}`}
>
{children}
</div>
</div>
);
}
function Message({
message, last, busy, onRetry, onFollowUp,
}: {
message: ChatMessage;
last: boolean;
busy: boolean;
onRetry: (m: ChatMessage) => void;
onFollowUp: (q: string) => void;
}) {
const toast = useToast();
if (message.role === 'user') return <Bubble role="user">{message.text}</Bubble>;
if (!message.text) {
return (
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#f5f1ff]">
<SeyarMascot char="pi" mood="idle" size={32} />
</span>
<div className="flex items-center gap-2 rounded-2xl border border-line bg-white px-4 py-3 text-xs text-muted">
پی داره فکر می‌کنه
{[0, 1, 2].map((i) => (
<motion.span key={i} className="h-1.5 w-1.5 rounded-full bg-violet/60" animate={{ y: [0, -4, 0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }} />
))}
</div>
</div>
);
}
async function rate(helpful: boolean) {
if (!message.messageId || message.feedback) return;
askStore.update(message.localId, { feedback: helpful ? 'up' : 'down' });
try {
await api.ai.feedback(message.messageId, helpful);
toast(helpful ? 'ممنون! 🙂' : 'ممنون که گفتی؛ بهترش می‌کنیم.', 'success');
} catch {
askStore.update(message.localId, { feedback: undefined });
toast('نظرت ثبت نشد', 'error');
}
}
return (
<div className="flex flex-col gap-1.5">
<Bubble role="assistant">{message.text}</Bubble>
<div className="flex flex-wrap items-center gap-2 pr-10">
{message.failedQuestion ? (
<button type="button" disabled={busy} onClick={() => onRetry(message)} className="flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-bold text-coral ring-1 ring-line disabled:opacity-50">
<RotateCw size={12} /> دوباره امتحان کن
</button>
) : (
<>
{message.usedChunks && message.usedChunks.length > 0 && (
<span className="flex items-center gap-1 text-[10px] font-bold text-mint" title={message.usedChunks.map((c) => c.title).join('، ')}>
<BookOpen size={11} /> از روی کتاب
</span>
)}
{message.messageId && (
<span className="mr-auto flex gap-1">
<button type="button" aria-label="مفید بود" onClick={() => void rate(true)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'up' ? 'bg-green-100 text-green-600' : 'text-faint'}`}>
<ThumbsUp size={13} />
</button>
<button type="button" aria-label="مفید نبود" onClick={() => void rate(false)} disabled={!!message.feedback} className={`rounded-full p-1.5 ${message.feedback === 'down' ? 'bg-red-100 text-red-500' : 'text-faint'}`}>
<ThumbsDown size={13} />
</button>
</span>
)}
</>
)}
</div>
{last && !busy && message.followUps && message.followUps.length > 0 && (
<div className="flex flex-wrap gap-2 pr-10">
{message.followUps.map((f) => (
<button key={f} type="button" onClick={() => onFollowUp(f)} className="rounded-full border border-[#dcd2ff] bg-white px-3 py-1.5 text-xs font-bold text-[#7857E0]">
{f}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useState } from 'react';
import { Check, Lock, Sparkles } from 'lucide-react';
import { api, type Character } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { SEYAR_INFO, seyarFromSlug } from '@/mascots/seyar';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { toFa } from '@/lib/format';
/**
* همراه‌ها (کاتالوگِ کاراکترهای بک‌اند). همراهِ انتخابی در خانه، درس و تمرین
* نشان داده می‌شود. باز شدن فقط با امتیاز است.
*/
export function CharactersScreen() {
const { summary, setCompanion } = useAppData();
const toast = useToast();
const list = useAsync(() => api.characters.list(), []);
const [busy, setBusy] = useState<string | null>(null);
async function select(c: Character) {
if (!c.unlocked || c.selected || busy) return;
setBusy(c.characterId);
try {
await api.characters.select(c.characterId);
list.setData((prev) => prev?.map((x) => ({ ...x, selected: x.characterId === c.characterId })));
setCompanion(c);
toast(`${c.name} حالا همراهته!`, 'success');
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setBusy(null);
}
}
return (
<Screen>
<ScreenHeader
title="همراهت رو انتخاب کن"
subtitle={`امتیازِ تو: ${toFa(summary?.xp ?? 0)} · با امتیازِ بیشتر همراه‌های بیشتری باز می‌شن`}
color="#9B7BFF"
back
/>
<div className="flex-1 overflow-y-auto px-4 py-5">
{list.loading ? (
<LoadingState />
) : list.error ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : !list.data?.length ? (
<EmptyState text="فعلاً همراهی تعریف نشده" />
) : (
<div className="grid grid-cols-2 gap-3">
{list.data.map((c) => {
const seyar = seyarFromSlug(c.slug);
return (
<button
key={c.characterId}
type="button"
disabled={!c.unlocked || busy !== null}
onClick={() => void select(c)}
aria-pressed={c.selected}
className={`relative flex flex-col items-center gap-2 rounded-3xl border-2 p-4 transition-transform ${
c.selected ? 'border-violet bg-[#F5F1FF]' : c.unlocked ? 'border-line bg-white active:scale-95' : 'border-line bg-[#f4f3fa]'
}`}
>
{c.selected && (
<span className="absolute left-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-violet">
<Check size={11} className="text-white" strokeWidth={3} />
</span>
)}
<span className={`relative ${c.unlocked ? '' : 'opacity-50 grayscale'}`}>
{seyar ? (
<SeyarMascot char={seyar} mood={c.unlocked ? 'happy' : 'idle'} size={84} />
) : (
<span className="flex h-20 w-20 items-center justify-center rounded-full bg-[#EEF1FF]">
<Sparkles size={28} className="text-violet" />
</span>
)}
</span>
{!c.unlocked && (
<span className="absolute top-12 flex h-9 w-9 items-center justify-center rounded-full bg-white shadow">
<Lock size={16} className="text-[#5a5580]" />
</span>
)}
<span className="text-sm font-bold text-ink">{c.name}</span>
<span className="text-center text-[10px] leading-5 text-muted">
{!c.unlocked
? `با ${toFa(c.unlockXp)} امتیاز باز می‌شه`
: c.selected
? 'همراهِ فعلی'
: seyar
? SEYAR_INFO[seyar].role
: 'بازشده'}
</span>
</button>
);
})}
</div>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,168 @@
import { useState } from 'react';
import { BookOpen, Flame, Link2, Star, Trophy, UserPlus } from 'lucide-react';
import { api, ApiError, type FamilyLink } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { InlineError, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { digitsOnly, formatJalaliDate, toFa } from '@/lib/format';
/**
* بخشِ والدین. والد با شماره‌ی فرزند دعوت می‌سازد و یک کد می‌گیرد؛ فرزند
* (با همان شماره وارد شده) کد را اینجا وارد می‌کند. بعد از اتصال، والد
* گزارشِ هفتگی را می‌بیند.
*/
export function FamilyScreen() {
const link = useAsync(
() => api.family.link().catch((err) => {
if (err instanceof ApiError && err.status === 404) return null;
throw err;
}),
[],
);
return (
<Screen>
<ScreenHeader title="بخشِ والدین" subtitle="پیشرفتِ فرزندت رو هر هفته ببین" color="#FF7A3D" back mascot={{ char: 'sigma', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{link.loading ? (
<LoadingState />
) : (
<>
{link.data && <LinkStatus link={link.data} />}
{link.data?.status === 'accepted' && <WeeklyReport />}
<InviteChild onDone={link.reload} />
<AcceptInvite onDone={link.reload} />
</>
)}
</ScreenBody>
</Screen>
);
}
function LinkStatus({ link }: { link: FamilyLink }) {
const accepted = link.status === 'accepted';
return (
<div className={`flex items-center gap-3 rounded-3xl p-4 ${accepted ? 'bg-[#EAF8F0]' : 'bg-[#FFF8E1]'}`}>
<Link2 size={20} className={accepted ? 'text-mint' : 'text-gold'} />
<p className="text-sm leading-7 text-ink">
{accepted ? <>به <b>{link.childName || 'فرزندت'}</b> وصلی.</> : 'دعوت فرستاده شده و منتظرِ تأییدِ فرزندته.'}
</p>
</div>
);
}
function WeeklyReport() {
const report = useAsync(() => api.family.weeklyReport(), []);
if (report.loading) return <div className="h-32 animate-pulse rounded-3xl bg-soft" />;
if (!report.data) return null;
const r = report.data;
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-3 text-sm font-black text-ink">گزارشِ این هفته <span className="text-xs font-medium text-muted">(از {formatJalaliDate(r.weekStart)})</span></h2>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: Star, label: 'امتیاز', value: r.xpEarned },
{ icon: BookOpen, label: 'درسِ تمام‌شده', value: r.lessonsCompleted },
{ icon: Trophy, label: 'جزیره‌ی تمام‌شده', value: r.islandsCompleted },
{ icon: Flame, label: 'زنجیره (روز)', value: r.currentStreak },
].map(({ icon: Icon, label, value }) => (
<div key={label} className="flex items-center gap-2 rounded-2xl bg-soft px-3 py-2.5">
<Icon size={16} className="text-orange" />
<span className="flex-1 text-xs text-body">{label}</span>
<span className="text-sm font-black text-ink">{toFa(value)}</span>
</div>
))}
</div>
</section>
);
}
function InviteChild({ onDone }: { onDone: () => void }) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function invite() {
setBusy(true);
setError('');
try {
const r = await api.family.invite(phone);
setCode(r.inviteCode);
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 flex items-center gap-2 text-sm font-black text-ink"><UserPlus size={16} className="text-orange" /> من والدم</h2>
<p className="mb-3 text-xs leading-6 text-muted">شماره‌ای که فرزندت باهاش وارد تدریسینو شده رو بنویس.</p>
{code ? (
<div className="rounded-2xl bg-soft p-4 text-center">
<p className="text-xs text-muted">این کد رو به فرزندت بده تا تو بخشِ والدینِ حسابِ خودش وارد کنه:</p>
<p dir="ltr" className="mt-2 text-2xl font-black tracking-widest text-ink">{code}</p>
</div>
) : (
<>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-left outline-none focus:border-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!/^09\d{9}$/.test(phone)} onClick={() => void invite()}>
ساختِ کدِ دعوت
</PrimaryButton>
</>
)}
</section>
);
}
function AcceptInvite({ onDone }: { onDone: () => void }) {
const toast = useToast();
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function accept() {
setBusy(true);
setError('');
try {
await api.family.accept(code.trim());
toast('به والدت وصل شدی', 'success');
setCode('');
onDone();
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}
return (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-1 text-sm font-black text-ink">من دانش‌آموزم</h2>
<p className="mb-3 text-xs leading-6 text-muted">اگه پدر یا مادرت کدِ دعوت بهت داده، اینجا واردش کن.</p>
<input
value={code}
onChange={(e) => setCode(e.target.value.slice(0, 32))}
placeholder="کدِ دعوت"
dir="ltr"
className="mb-3 w-full rounded-2xl border-2 border-line px-4 py-3 text-center font-bold tracking-widest outline-none focus:border-orange"
/>
{error && <div className="mb-3"><InlineError>{error}</InlineError></div>}
<PrimaryButton color="#FF7A3D" loading={busy} disabled={!code.trim()} onClick={() => void accept()}>
وصل شو
</PrimaryButton>
</section>
);
}

180
src/screens/HomeScreen.tsx Normal file
View File

@@ -0,0 +1,180 @@
import { Bell, Brain, ChevronLeft, Coins, Flame, Map, Play, Star, Trophy } from 'lucide-react';
import { useAppData } from '@/state/AppData';
import { findContinue, nextLessonOf, unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Card, ErrorState, ProgressBar, Screen } from '@/components/ui';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
export function HomeScreen() {
const { profile, gradeTitle, summary, home, homeError, companion, unreadCount, refreshHome } = useAppData();
const nav = useNav();
const cont = findContinue(home);
const contLesson = cont ? nextLessonOf(cont.node) : null;
const contColor = cont ? unitColor(cont.unitIndex) : '#ED6956';
const contProgress = cont ? unitProgress(cont.unit) : null;
const openLesson = (quiz = false) => {
if (!cont || contLesson == null) return;
nav.push({ name: quiz ? 'quiz' : 'lesson', lessonId: contLesson, nodeId: cont.node.id });
};
const stats = [
{ icon: Flame, value: summary?.streak.current ?? 0, label: 'روزِ پیاپی', fg: '#FF7A3D', bg: '#FFF1E6', fill: true, to: 'streak' as const },
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز', fg: '#E8A400', bg: '#FFF8E1', fill: false, to: 'leaderboard' as const },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه', fg: '#9B7BFF', bg: '#F5F1FF', fill: false, to: 'characters' as const },
];
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<header className="relative overflow-hidden rounded-b-[32px] bg-coral px-5 pb-11 pt-safe text-white">
<div className="pointer-events-none absolute -left-10 -top-10 h-48 w-48 rounded-full bg-white/5" />
<div className="relative flex items-center justify-between pt-8">
<div className="min-w-0">
<p className="text-sm text-white/75">سلام {profile.name || 'دوستِ من'}!</p>
<h1 className="mt-0.5 text-xl font-black leading-snug">آماده‌ای ریاضی رو ادامه بدیم؟</h1>
{gradeTitle && (
<span className="mt-1.5 inline-block rounded-full bg-white/15 px-2.5 py-0.5 text-xs text-white/85">{gradeTitle}</span>
)}
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<button type="button" aria-label="جدولِ امتیازها" onClick={() => nav.push({ name: 'leaderboard' })} className="flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Trophy size={18} />
</button>
<button type="button" aria-label="اعلان‌ها" onClick={() => nav.push({ name: 'notifications' })} className="relative flex h-10 w-10 items-center justify-center rounded-full bg-white/15">
<Bell size={18} />
{unreadCount > 0 && (
<span className="absolute -left-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-[#FFD23F] px-1 text-[9px] font-black text-ink">
{unreadCount > 9 ? '۹+' : toFa(unreadCount)}
</span>
)}
</button>
</div>
</div>
</header>
<div className="relative z-10 -mt-7 px-4">
<div className="flex rounded-3xl border border-line bg-white shadow-lg shadow-black/10">
{stats.map(({ icon: Icon, value, label, fg, bg, fill, to }) => (
<button key={label} type="button" onClick={() => nav.push({ name: to })} className="flex flex-1 flex-col items-center py-3.5">
<span className="mb-1.5 flex h-10 w-10 items-center justify-center rounded-full" style={{ backgroundColor: bg }}>
<Icon size={18} style={{ color: fg }} strokeWidth={2.4} fill={fill ? fg : 'none'} />
</span>
<span className="text-lg font-black leading-none text-ink">{toFa(value)}</span>
<span className="mt-1 text-[10px] text-muted">{label}</span>
</button>
))}
</div>
</div>
<div className="flex flex-col gap-4 px-4 pb-6 pt-4">
{homeError && !home ? (
<ErrorState error={homeError} onRetry={() => void refreshHome()} />
) : cont && contProgress ? (
<Card className="shadow-md shadow-coral/10">
<div className="mb-3 flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-muted">ادامه از جایی که موندی</p>
<h3 className="mt-0.5 text-base font-black text-ink">{cont.node.title}</h3>
<p className="mt-0.5 truncate text-xs text-muted">
{cont.unit.title} · {toFa(contProgress.done)} از {toFa(contProgress.total)} جزیره
</p>
</div>
<SeyarMascot char={companion} mood="happy" size={62} className="mr-3" />
</div>
<div className="mb-3">
<ProgressBar percent={contProgress.percent} color={contColor} />
</div>
<button
type="button"
onClick={() => openLesson()}
className="flex w-full items-center justify-center gap-2 rounded-2xl py-3 text-sm font-bold text-white active:scale-[0.98]"
style={{ backgroundColor: contColor }}
>
<Play size={15} fill="white" /> ادامه بده
</button>
</Card>
) : (
<Card className="flex items-center gap-3 p-5">
<SeyarMascot char={companion} mood={home ? 'excited' : 'idle'} size={56} />
<p className="text-sm font-medium text-muted">
{!home ? 'دارم مسیرت رو میارم…' : home.units.length ? 'همه‌ی جزیره‌های این پایه رو تموم کردی! 🎉' : 'هنوز درسی برای این پایه ثبت نشده'}
</p>
</Card>
)}
<div className="grid grid-cols-2 gap-3">
<Card onClick={() => nav.switchTab('map')} className="p-4">
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-[#EAF2F6]">
<Map size={20} className="text-steel" />
</span>
<div className="text-sm font-bold text-ink">نقشه‌ی فصل‌ها</div>
<div className="mt-0.5 text-[11px] text-muted">ببین کجای مسیری</div>
</Card>
<button
type="button"
onClick={() => nav.switchTab('ask')}
className="rounded-3xl bg-violet p-4 text-right shadow-md shadow-violet/30 active:scale-[0.98]"
>
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl bg-white/15">
<Brain size={20} className="text-white" />
</span>
<div className="text-sm font-bold text-white">از پی بپرس</div>
<div className="mt-0.5 text-[11px] text-white/75">هر جا گیر کردی</div>
</button>
</div>
{home && home.units.length > 0 && (
<section>
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-black text-ink">فصل‌های {gradeTitle || 'این پایه'}</h2>
<span className="text-[11px] text-muted">{toFa(home.units.length)} فصل</span>
</div>
<div className="no-scrollbar -mx-4 flex gap-3 overflow-x-auto px-4 pb-1">
{home.units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i);
return (
<button
key={unit.id}
type="button"
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="w-[140px] flex-shrink-0 rounded-2xl border border-line bg-white p-3.5 text-right shadow-sm active:scale-95"
>
<span className="mb-2.5 flex h-10 w-10 items-center justify-center rounded-xl" style={{ backgroundColor: `${color}1f` }}>
<Icon size={19} style={{ color }} />
</span>
<div className="line-clamp-2 min-h-[2.5em] text-[12.5px] font-bold leading-snug text-ink">{unit.title}</div>
<div className="mt-1 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
<div className="mt-2">
<ProgressBar percent={p.percent} color={color} track="#f0eeff" height={6} />
</div>
</button>
);
})}
</div>
</section>
)}
{cont && contLesson != null && (
<button
type="button"
onClick={() => openLesson(true)}
className="flex items-center gap-3 rounded-3xl bg-orange p-4 text-right shadow-md shadow-orange/25 active:scale-[0.98]"
>
<SeyarMascot char="delta" mood="excited" size={64} />
<div className="min-w-0 flex-1">
<div className="text-sm font-black text-white">یه سؤال دیگه… فقط یکی!</div>
<div className="mt-0.5 truncate text-[11px] text-white/80">چالشِ سریع با دلتا · {cont.node.title}</div>
</div>
<ChevronLeft size={18} className="flex-shrink-0 text-white/80" />
</button>
)}
</div>
</div>
</Screen>
);
}

View File

@@ -0,0 +1,85 @@
import { Copy, Gift, Share2, Users } from 'lucide-react';
import { api } from '@/api';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { formatToman, toFa } from '@/lib/format';
/** لینکِ دعوت: هر کس با آن بیاید و شماره ثبت کند، معرفیِ تو حساب می‌شود. */
export function inviteLink(code: string): string {
return `${window.location.origin}/?ref=${encodeURIComponent(code)}`;
}
export function InviteScreen() {
const toast = useToast();
const me = useAsync(() => api.affiliate.me(), []);
async function share(code: string) {
const url = inviteLink(code);
const text = 'با تدریسینو ریاضی رو قدم‌به‌قدم یاد بگیر:';
if (navigator.share) {
try {
await navigator.share({ title: 'تدریسینو', text, url });
return;
} catch {
/* کاربر لغو کرد؛ می‌رویم سراغِ کپی */
}
}
try {
await navigator.clipboard.writeText(`${text} ${url}`);
toast('لینک کپی شد', 'success');
} catch {
toast(url);
}
}
return (
<Screen>
<ScreenHeader title="معرفی به دوستان" subtitle="دوستات رو بیار، با هم ریاضی بخونید" color="#5FBF8F" back mascot={{ char: 'delta', mood: 'happy', size: 56 }} />
<ScreenBody className="flex flex-col gap-4">
{me.loading ? (
<LoadingState />
) : me.error || !me.data ? (
<ErrorState error={me.error} onRetry={me.reload} />
) : (
<>
<div className="flex flex-col items-center gap-2 rounded-3xl border border-line bg-white p-5 text-center">
<Gift size={28} className="text-mint" />
<span className="text-xs text-muted">کدِ معرفیِ تو</span>
<button
type="button"
onClick={() => void navigator.clipboard?.writeText(me.data!.referralCode).then(() => toast('کد کپی شد', 'success'))}
className="flex items-center gap-2 rounded-2xl bg-soft px-5 py-2.5 text-xl font-black tracking-widest text-ink"
dir="ltr"
>
{me.data.referralCode} <Copy size={16} className="text-muted" />
</button>
</div>
<div className="grid grid-cols-3 gap-2">
{[
{ label: 'معرفی‌شده', value: toFa(me.data.referredCount) },
{ label: 'خرید کرده', value: toFa(me.data.paidCount) },
{ label: 'پورسانت', value: formatToman(me.data.totalCommission) },
].map((s) => (
<div key={s.label} className="flex flex-col items-center rounded-2xl border border-line bg-white px-1 py-3 text-center">
<span className="text-sm font-black text-ink">{s.value}</span>
<span className="mt-0.5 text-[10px] text-muted">{s.label}</span>
</div>
))}
</div>
<p className="flex items-start gap-2 rounded-2xl bg-[#EAF8F0] p-3.5 text-xs leading-6 text-body">
<Users size={16} className="mt-0.5 flex-shrink-0 text-mint" />
دوستت باید با لینکِ تو وارد بشه و شماره‌ش رو ثبت کنه. اگه اشتراک خرید، {toFa(Math.round(me.data.commissionRate * 100))}٪ مبلغ پورسانتِ توئه.
</p>
<PrimaryButton color="#5FBF8F" onClick={() => void share(me.data!.referralCode)}>
<Share2 size={17} /> فرستادنِ لینکِ دعوت
</PrimaryButton>
</>
)}
</ScreenBody>
</Screen>
);
}

View File

@@ -0,0 +1,87 @@
import { useState } from 'react';
import { api, type LeaderboardPeriod } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
const MEDAL: Record<number, { bg: string; fg: string; h: number }> = {
1: { bg: '#FFD23F', fg: '#8a6d00', h: 92 },
2: { bg: '#D9DEE8', fg: '#5a6270', h: 72 },
3: { bg: '#E8B27A', fg: '#7a4a1a', h: 56 },
};
/** جدولِ امتیازها؛ هر بار که باز شود تازه گرفته می‌شود. */
export function LeaderboardScreen() {
const { profile } = useAppData();
const [period, setPeriod] = useState<LeaderboardPeriod>('weekly');
const board = useAsync(() => api.leaderboard(period), [period]);
const entries = board.data ?? [];
const podium = [entries[1], entries[0], entries[2]].filter(Boolean);
const isMe = (userId: string) => userId === profile.userId;
return (
<Screen>
<ScreenHeader title="جدولِ امتیازها" color="#FF7A3D" back mascot={{ char: 'delta', mood: 'excited', size: 56 }}>
<div className="mt-4 flex gap-1 rounded-2xl bg-white/15 p-1" role="tablist">
{([['weekly', 'این هفته'], ['all_time', 'همه‌ی زمان‌ها']] as const).map(([p, label]) => (
<button
key={p}
type="button"
role="tab"
aria-selected={period === p}
onClick={() => setPeriod(p)}
className={`flex-1 rounded-xl py-2 text-xs font-bold ${period === p ? 'bg-white text-orange' : 'text-white/85'}`}
>
{label}
</button>
))}
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">
{board.loading ? (
<LoadingState char="delta" text="دارم جدول رو میارم…" />
) : board.error ? (
<ErrorState error={board.error} onRetry={board.reload} />
) : entries.length === 0 ? (
<EmptyState text={period === 'weekly' ? 'این هفته هنوز کسی امتیاز نگرفته؛ اولی تو باش!' : 'هنوز کسی تو جدول نیست'} />
) : (
<>
<div className="mb-6 flex items-end justify-center gap-2.5 pt-4">
{podium.map((e) => {
const m = MEDAL[e.rank] ?? MEDAL[3];
return (
<div key={e.userId} className="flex w-24 flex-col items-center gap-1.5">
<span
className={`flex h-14 w-14 items-center justify-center rounded-full border-2 text-lg font-black ${isMe(e.userId) ? 'border-coral' : 'border-white'}`}
style={{ backgroundColor: m.bg, color: m.fg }}
>
{toFa(e.rank)}
</span>
<span className={`w-full truncate text-center text-xs font-bold ${isMe(e.userId) ? 'text-coral' : 'text-ink'}`}>{isMe(e.userId) ? 'تو' : e.name || 'بی‌نام'}</span>
<span className="text-[10px] text-muted">{toFa(e.xpInPeriod)} امتیاز</span>
<span className="mt-1 w-full rounded-t-xl" style={{ height: m.h, backgroundColor: m.bg, opacity: 0.35 }} />
</div>
);
})}
</div>
<ul className="flex flex-col gap-2">
{entries.slice(3).map((e) => (
<li
key={e.userId}
className={`flex items-center gap-3 rounded-2xl p-3 ${isMe(e.userId) ? 'border-2 border-orange/40 bg-[#FFF1E6]' : 'border border-line bg-white'}`}
>
<span className="w-7 flex-shrink-0 text-center text-sm font-black text-muted">{toFa(e.rank)}</span>
<span className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-soft text-xs font-bold text-muted">{e.name?.[0] ?? '؟'}</span>
<span className={`flex-1 truncate text-sm ${isMe(e.userId) ? 'font-bold text-[#B34E1C]' : 'text-ink'}`}>{isMe(e.userId) ? 'تو' : e.name || 'بی‌نام'}</span>
<span className="flex-shrink-0 text-xs font-bold text-muted">{toFa(e.xpInPeriod)} امتیاز</span>
</li>
))}
</ul>
</>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,102 @@
import { Brain, Play, Zap } from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
import { findNode, findNodeOfLesson, unitColor } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { useAsync } from '@/lib/useAsync';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { moodFromServer } from '@/mascots/seyar';
import { ErrorState, LoadingState, PrimaryButton, Screen, ScreenBody, ScreenHeader } from '@/components/ui';
import { toFa } from '@/lib/format';
/** معرفیِ درس، پیش از تمرین. */
export function LessonScreen({ lessonId, nodeId }: { lessonId: number; nodeId?: string }) {
const { home, companion } = useAppData();
const nav = useNav();
const intro = useAsync(() => api.lesson.intro(lessonId), [lessonId]);
const ref = findNode(home, nodeId) ?? findNodeOfLesson(home, lessonId);
const color = ref ? unitColor(ref.unitIndex) : '#ED6956';
const data = intro.data;
return (
<Screen>
<ScreenHeader
title={data?.title ?? '…'}
color={color}
back
mascot={{ char: companion, mood: moodFromServer(data?.mascotAnimation), size: 72 }}
subtitle={
ref && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/15 px-3 py-1">
{/* ترتیبِ lessonIds «درسِ بعدی اول» است، نه ترتیبِ کتاب؛ پس شماره‌ی درس از آن درنمی‌آید. */}
<Zap size={11} fill="white" /> {ref.node.title} · {toFa(ref.node.lessonIds.length)} درس
</span>
)
}
/>
<ScreenBody className="flex flex-col gap-4">
{intro.loading ? (
<LoadingState text="دارم درس رو میارم…" />
) : intro.error || !data ? (
<ErrorState error={intro.error} onRetry={intro.reload} />
) : (
<>
<div className="flex items-start gap-3">
<SeyarMascot char="sigma" mood="idle" size={56} />
<div className="flex-1 rounded-3xl rounded-tr-sm border border-line bg-white p-4 shadow-sm">
<p className="whitespace-pre-line text-sm leading-[1.95] text-body">{data.introText}</p>
</div>
</div>
{data.kind === 'video' && data.videoUrl && (
<video src={data.videoUrl} controls playsInline preload="metadata" className="w-full rounded-3xl bg-black" />
)}
{data.example && (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-2 text-xs font-black text-muted">مثال</h2>
<p className="whitespace-pre-line text-sm leading-[1.95] text-ink">{data.example}</p>
</section>
)}
{data.steps.length > 0 && (
<section className="rounded-3xl border border-line bg-white p-4">
<h2 className="mb-3 text-xs font-black text-muted">قدم‌به‌قدم</h2>
<ol className="flex flex-col gap-2.5">
{data.steps.map((step, i) => (
<li key={i} className="flex gap-3 text-sm leading-7 text-ink">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-black text-white" style={{ backgroundColor: color }}>
{toFa(i + 1)}
</span>
<span className="flex-1 whitespace-pre-line">{step}</span>
</li>
))}
</ol>
</section>
)}
<div className="mt-auto flex flex-col gap-3 pt-2">
{data.questionCount === 0 ? (
<div className="rounded-2xl border-2 border-dashed border-[#dedced] bg-white py-4 text-center text-sm font-bold text-muted">
تمرینِ این درس به‌زودی اضافه می‌شه
</div>
) : (
<PrimaryButton color={color} onClick={() => nav.replace({ name: 'quiz', lessonId, nodeId: ref?.node.id ?? nodeId })}>
<Play size={18} fill="white" /> شروعِ تمرین ({toFa(data.questionCount)} سوال)
</PrimaryButton>
)}
<button
type="button"
onClick={() => nav.push({ name: 'ask', lessonId })}
className="flex items-center justify-center gap-2 rounded-2xl bg-[#F5F1FF] py-3 text-sm font-bold text-[#7857E0]"
>
<Brain size={16} /> درباره‌ی این درس از پی بپرس
</button>
</div>
</>
)}
</ScreenBody>
</Screen>
);
}

209
src/screens/LoginScreen.tsx Normal file
View File

@@ -0,0 +1,209 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronRight, Phone } from 'lucide-react';
import { api } from '@/api';
import { InlineError, PrimaryButton } from '@/components/ui';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { friendlyError } from '@/lib/errors';
import { digitsOnly, toFa } from '@/lib/format';
import { getDeviceId } from '@/lib/storage';
const OTP_LENGTH = 6;
/** همان cooldownِ سرور (OTP_REQUEST_PHONE_COOLDOWN_SECONDS). */
const RESEND_SECONDS = 120;
/**
* ورود با شماره (OTP) یا به‌عنوانِ مهمان.
*
* `upgrade` برای مهمانی است که از صفحه‌ی «من» می‌خواهد شماره ثبت کند؛ آنجا
* گزینه‌ی مهمان نیست و سرور همان کاربر را نگه می‌دارد.
*/
export function LoginScreen({
onDone,
onCancel,
upgrade = false,
}: {
onDone: () => void;
onCancel?: () => void;
upgrade?: boolean;
}) {
const [step, setStep] = useState<'phone' | 'otp'>('phone');
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');
const [devOtp, setDevOtp] = useState<string | null>(null);
const [loading, setLoading] = useState<'otp' | 'login' | 'guest' | null>(null);
const [error, setError] = useState('');
const [resendIn, setResendIn] = useState(0);
const otpInput = useRef<HTMLInputElement>(null);
const phoneValid = /^09\d{9}$/.test(phone);
const otpValid = otp.length >= 4;
useEffect(() => {
if (resendIn <= 0) return;
const t = setTimeout(() => setResendIn((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [resendIn]);
useEffect(() => {
if (step === 'otp') otpInput.current?.focus();
}, [step]);
async function requestOtp() {
if (!phoneValid || loading) return;
setError('');
setLoading('otp');
try {
const data = await api.auth.requestOtp(phone);
setDevOtp(data.devOtp ?? null);
setOtp('');
setStep('otp');
setResendIn(RESEND_SECONDS);
} catch (err) {
setError(friendlyError(err, 'ارسالِ کد انجام نشد، دوباره امتحان کن'));
} finally {
setLoading(null);
}
}
async function verify(code = otp) {
if (code.length < 4 || loading) return;
setError('');
setLoading('login');
try {
await api.auth.login(phone, code);
onDone();
} catch (err) {
setError(friendlyError(err, 'کد درست نبود، دوباره امتحان کن'));
setOtp('');
} finally {
setLoading(null);
}
}
async function continueAsGuest() {
setError('');
setLoading('guest');
try {
await api.auth.guest(getDeviceId());
onDone();
} catch (err) {
// قبلاً اینجا یک «مهمانِ محلی» بدونِ توکن ساخته می‌شد و بعد همه‌ی
// درخواست‌ها بی‌صدا شکست می‌خوردند. حالا همین‌جا می‌گوییم چه شده.
setError(friendlyError(err, 'ورودِ مهمان انجام نشد، دوباره امتحان کن'));
} finally {
setLoading(null);
}
}
function onOtpChange(value: string) {
const code = digitsOnly(value, OTP_LENGTH);
setOtp(code);
if (code.length === OTP_LENGTH) void verify(code);
}
return (
<div className="flex h-full flex-col bg-page">
<header className="rounded-b-[32px] bg-coral px-5 pb-6 pt-safe text-white">
<div className="pt-8">
{onCancel && (
<button type="button" aria-label="بازگشت" onClick={onCancel} className="mb-3 flex h-9 w-9 items-center justify-center rounded-full bg-white/15">
<ChevronRight size={18} strokeWidth={2.5} />
</button>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-black">{upgrade ? 'ثبتِ شماره‌ی موبایل' : 'ورود به تدریسینو'}</h1>
<p className="mt-1 text-sm text-white/75">
{step === 'phone'
? upgrade ? 'امتیازها و پیشرفتت سرِ جاش می‌مونه' : 'شماره‌ی موبایلت رو وارد کن'
: `کدِ ${toFa(OTP_LENGTH)} رقمیِ پیامک‌شده رو وارد کن`}
</p>
</div>
<div className="rounded-2xl bg-white/15 p-2">
<SeyarMascot char="sigma" mood="happy" size={68} />
</div>
</div>
</div>
</header>
<form
className="flex flex-1 flex-col gap-5 overflow-y-auto px-5 py-6"
onSubmit={(e) => {
e.preventDefault();
void (step === 'phone' ? requestOtp() : verify());
}}
>
{step === 'phone' ? (
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">شماره موبایل</span>
<input
value={phone}
onChange={(e) => setPhone(digitsOnly(e.target.value, 11))}
placeholder="۰۹۱۲۳۴۵۶۷۸۹"
dir="ltr"
inputMode="numeric"
autoComplete="tel"
autoFocus
className={`w-full rounded-2xl border-2 bg-white px-4 py-4 text-left text-base font-medium tracking-wider text-ink outline-none transition-colors placeholder:text-faint ${
error ? 'border-red-400' : phone ? 'border-coral' : 'border-line'
}`}
/>
</label>
) : (
<div>
<label className="block">
<span className="mb-2 block text-sm font-bold text-ink">کدِ تأیید</span>
<input
ref={otpInput}
value={otp}
onChange={(e) => onOtpChange(e.target.value)}
placeholder="––––––"
dir="ltr"
inputMode="numeric"
autoComplete="one-time-code"
className={`w-full rounded-2xl border-2 bg-white px-4 py-4 text-center text-2xl font-black tracking-[0.5em] text-ink outline-none transition-colors placeholder:text-faint ${
error ? 'border-red-400' : otp ? 'border-coral' : 'border-line'
}`}
/>
</label>
{devOtp && (
<p className="mt-2 text-xs text-muted">
کدِ آزمایشیِ حالتِ توسعه: <span dir="ltr" className="font-bold text-coral">{devOtp}</span>
</p>
)}
<div className="mt-3 flex items-center justify-between text-xs font-bold">
<button type="button" className="flex items-center gap-1 text-coral" onClick={() => { setStep('phone'); setError(''); setDevOtp(null); }}>
<Phone size={12} /> <span dir="ltr">{toFa(phone)}</span> · اصلاح
</button>
<button type="button" disabled={resendIn > 0 || loading !== null} onClick={() => void requestOtp()} className="text-coral disabled:text-muted">
{resendIn > 0 ? `ارسالِ دوباره تا ${toFa(resendIn)} ثانیه` : 'ارسالِ دوباره'}
</button>
</div>
</div>
)}
{error && <InlineError>{error}</InlineError>}
<div className="mt-auto flex flex-col gap-3">
<PrimaryButton
type="submit"
loading={loading === 'otp' || loading === 'login'}
disabled={step === 'phone' ? !phoneValid : !otpValid}
>
{step === 'phone' ? 'دریافتِ کدِ تأیید' : 'ورود'}
</PrimaryButton>
{!upgrade && (
<button
type="button"
onClick={() => void continueAsGuest()}
disabled={loading !== null}
className="py-2 text-center text-sm font-bold text-muted disabled:opacity-40"
>
{loading === 'guest' ? 'در حالِ ورود…' : 'فعلاً مهمان باشم'}
</button>
)}
</div>
</form>
</div>
);
}

96
src/screens/MapScreen.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { motion } from 'motion/react';
import { Check } from 'lucide-react';
import { useAppData } from '@/state/AppData';
import { unitColor, unitProgress } from '@/state/curriculum';
import { useNav } from '@/nav/Navigator';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { EmptyState, ErrorState, LoadingState, ProgressBar, Screen, ScreenHeader } from '@/components/ui';
import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
import { iconFor } from '@/components/NodeIcon';
import { toFa } from '@/lib/format';
/** نقشه‌ی فصل‌های پایه: هر خانه یک فصل است. */
export function MapScreen() {
const { home, homeError, refreshHome, gradeTitle } = useAppData();
const nav = useNav();
const units = home?.units ?? [];
const points = zigzagPositions(units.length, 150);
const height = points.length ? points[points.length - 1].y + 200 : 400;
const overall = Math.round(home?.stats.progressPercent ?? 0);
return (
<Screen className="bg-[#f7f8ff]">
<ScreenHeader
title="نقشه‌ی فصل‌ها"
subtitle={`${gradeTitle ? `${gradeTitle} · ` : ''}${toFa(overall)}٪ از کلِ مسیر`}
color="#91ADC6"
mascot={{ char: 'sigma', mood: overall > 0 ? 'happy' : 'idle' }}
>
<div className="mt-4">
<ProgressBar percent={overall} color="#fff" track="rgba(255,255,255,.25)" />
</div>
</ScreenHeader>
<div className="flex-1 overflow-y-auto">
{!home ? (
homeError ? <ErrorState error={homeError} onRetry={() => void refreshHome()} /> : <LoadingState text="دارم فصل‌ها رو میارم…" />
) : units.length === 0 ? (
<EmptyState text="هنوز فصلی برای این پایه ثبت نشده" />
) : (
<div className="relative mx-auto w-full max-w-[400px]" style={{ height }}>
<ZigzagPath
points={points}
height={height}
colorOf={(i) => (units[i].nodes.some((n) => n.status !== 'locked') ? unitColor(i) : null)}
/>
{units.map((unit, i) => {
const p = unitProgress(unit);
const color = unitColor(i);
const Icon = iconFor(unit.nodes[0]?.icon, i);
const complete = p.total > 0 && p.done === p.total;
return (
<motion.div
key={unit.id}
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: i * 0.05, type: 'spring', stiffness: 220, damping: 16 }}
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
style={{ left: `${points[i].x}%`, top: points[i].y }}
>
<button
type="button"
aria-label={unit.title}
onClick={() => nav.push({ name: 'unit', unitId: unit.id })}
className="relative flex h-[84px] w-[84px] items-center justify-center rounded-[26px] transition-transform active:translate-y-1"
style={{
backgroundColor: complete ? color : '#fff',
border: complete ? 'none' : `4px solid ${color}`,
boxShadow: complete ? `0 6px 0 ${color}99` : `0 6px 0 ${color}40, 0 0 0 5px white`,
}}
>
{complete ? <Check size={30} color="white" strokeWidth={3.5} /> : <Icon size={30} style={{ color }} strokeWidth={2.2} />}
{!complete && p.done > 0 && (
<span className="absolute -left-2 -top-2 rounded-full border bg-white px-1.5 py-0.5 text-[10px] font-black shadow" style={{ color, borderColor: color }}>
{toFa(p.percent)}٪
</span>
)}
</button>
<div className="rounded-xl bg-[#f7f8ff]/90 px-2 py-0.5 text-center">
<div className="max-w-[130px] truncate text-xs font-black text-ink">{unit.title}</div>
<div className="mt-0.5 text-[10px] text-muted">{toFa(p.done)} از {toFa(p.total)} جزیره</div>
</div>
</motion.div>
);
})}
<div className="absolute left-1/2 flex -translate-x-1/2 flex-col items-center" style={{ top: height - 150 }}>
<SeyarMascot char="pi" mood={overall >= 100 ? 'excited' : 'happy'} size={72} bob />
<div className="mt-1 whitespace-nowrap rounded-full border border-line bg-white px-3 py-1 text-[10px] font-bold text-[#7857E0] shadow-sm">
{overall >= 100 ? 'همه رو تموم کردی! 🎉' : 'همین‌جا منتظرتم'}
</div>
</div>
</div>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,99 @@
import { Bell, Check, Crown, FileText, MessageCircle, Star, Trophy, Users, type LucideIcon } from 'lucide-react';
import { api, type NotificationItem } from '@/api';
import { useAppData } from '@/state/AppData';
import { useAsync } from '@/lib/useAsync';
import { useToast } from '@/components/Toast';
import { EmptyState, ErrorState, LoadingState, Screen, ScreenHeader } from '@/components/ui';
import { relativeTime } from '@/lib/format';
const STYLE: Record<string, { icon: LucideIcon; color: string; bg: string }> = {
xp_earned: { icon: Star, color: '#E8A400', bg: '#FFF8E1' },
lesson_completed: { icon: Check, color: '#5FBF8F', bg: '#EAF8F0' },
node_completed: { icon: Trophy, color: '#FF7A3D', bg: '#FFF1E6' },
order_paid: { icon: Crown, color: '#9B7BFF', bg: '#F5F1FF' },
ticket_replied: { icon: MessageCircle, color: '#4C5FD5', bg: '#EEF1FF' },
family_link_accepted: { icon: Users, color: '#FF7A3D', bg: '#FFF1E6' },
weekly_report_ready: { icon: FileText, color: '#91ADC6', bg: '#EAF2F6' },
};
const styleOf = (type: string) => STYLE[type] ?? { icon: Bell, color: '#9e9ec0', bg: '#f0eeff' };
export function NotificationsScreen() {
const { setUnreadCount } = useAppData();
const toast = useToast();
const list = useAsync(async () => {
const r = await api.notifications.list();
setUnreadCount(r.unreadCount);
return r.items;
}, []);
const items = list.data ?? [];
const unread = items.filter((n) => !n.isRead).length;
const markLocal = (predicate: (n: NotificationItem) => boolean) => {
list.setData((prev) => prev?.map((n) => (predicate(n) ? { ...n, isRead: true } : n)));
};
async function markOne(n: NotificationItem) {
if (n.isRead) return;
markLocal((x) => x.id === n.id);
setUnreadCount(Math.max(0, unread - 1));
api.notifications.markRead(n.id).catch(() => {});
}
async function markAll() {
markLocal(() => true);
setUnreadCount(0);
try {
await api.notifications.markAllRead();
} catch {
toast('ثبت نشد؛ دوباره امتحان کن', 'error');
list.reload();
}
}
return (
<Screen>
<ScreenHeader title="اعلان‌ها" color="#4C5FD5" back mascot={{ char: 'sigma', mood: 'idle', size: 54 }}>
{unread > 0 && (
<button type="button" onClick={() => void markAll()} className="mt-3 rounded-full bg-white/15 px-3 py-1.5 text-xs font-bold">
همه خوانده شد
</button>
)}
</ScreenHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">
{list.loading ? (
<LoadingState text="دارم اعلان‌ها رو میارم…" />
) : list.error ? (
<ErrorState error={list.error} onRetry={list.reload} />
) : items.length === 0 ? (
<EmptyState text="فعلاً خبری نیست" />
) : (
<ul className="flex flex-col gap-2">
{items.map((n) => {
const s = styleOf(n.type);
const Icon = s.icon;
return (
<li key={n.id}>
<button
type="button"
onClick={() => void markOne(n)}
className={`flex w-full items-start gap-3 rounded-2xl p-3.5 text-right ${n.isRead ? 'border border-line bg-white' : 'border border-indigo/20 bg-[#EEF1FF]'}`}
>
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full" style={{ backgroundColor: s.bg }}>
<Icon size={18} style={{ color: s.color }} />
</span>
<span className="min-w-0 flex-1">
<span className={`block text-sm ${n.isRead ? 'text-muted' : 'font-bold text-ink'}`}>{n.title}</span>
{n.body && <span className="mt-0.5 block whitespace-pre-line text-xs leading-6 text-body">{n.body}</span>}
<span className="mt-1 block text-[10px] text-faint">{relativeTime(n.createdAt)}</span>
</span>
{!n.isRead && <span className="mt-1.5 h-2 w-2 flex-shrink-0 rounded-full bg-coral" />}
</button>
</li>
);
})}
</ul>
)}
</div>
</Screen>
);
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { ChevronLeft, ChevronRight, Sparkles } from 'lucide-react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import type { SeyarChar, SeyarMood } from '@/mascots/seyar';
const SLIDES: { char: SeyarChar; mood: SeyarMood; bg: string; sub: string; title: string; desc: string }[] = [
{
char: 'sigma', mood: 'happy', bg: '#4C5FD5',
sub: 'از پایه‌ی سوم تا دوازدهم',
title: 'به تدریسینو خوش اومدی!',
desc: 'من سیگمام. ریاضیِ کتابِ خودت رو جزیره به جزیره با هم جلو می‌بریم؛ هر جزیره چند درسِ کوتاه و یه عالمه تمرین.',
},
{
char: 'delta', mood: 'excited', bg: '#FF7A3D',
sub: 'هر روز یه کم',
title: 'امتیاز بگیر، زنجیره بساز',
desc: 'منم دلتا! با هر جوابِ درست امتیاز و سکه می‌گیری، روزهای پیاپی‌ت شمرده می‌شه و تو جدولِ امتیازها بالا می‌ری.',
},
{
char: 'pi', mood: 'happy', bg: '#9B7BFF',
sub: 'هر جا گیر کردی',
title: 'از پی بپرس',
desc: 'من پی‌ام. هر سوالی داشتی، یا جوابی رو غلط زدی، ازم بپرس؛ از روی کتابِ خودت قدم‌به‌قدم توضیح می‌دم.',
},
];
export function OnboardingScreen({ onDone }: { onDone: () => void }) {
const [step, setStep] = useState(0);
const slide = SLIDES[step];
const last = step === SLIDES.length - 1;
return (
<div className="relative flex h-full flex-col text-white transition-colors duration-500" style={{ backgroundColor: slide.bg }}>
{step > 0 && (
<button
type="button"
aria-label="قبلی"
onClick={() => setStep((s) => s - 1)}
className="absolute right-5 top-10 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-white/15"
>
<ChevronRight size={18} strokeWidth={2.5} />
</button>
)}
<div className="flex flex-1 flex-col items-center justify-center px-8">
<AnimatePresence mode="wait">
<motion.div
key={step}
initial={{ opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.35 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="rounded-3xl border border-white/10 bg-white/10 p-6">
<SeyarMascot char={slide.char} mood={slide.mood} size={150} />
</div>
<div>
<p className="mb-1 text-sm font-medium text-white/70">{slide.sub}</p>
<h1 className="mb-3 text-2xl font-black leading-snug">{slide.title}</h1>
<p className="text-sm leading-[1.9] text-white/85">{slide.desc}</p>
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="flex justify-center gap-2 pb-4" aria-hidden>
{SLIDES.map((_, i) => (
<span key={i} className={`h-2 rounded-full transition-all duration-300 ${i === step ? 'w-6 bg-white' : 'w-2 bg-white/30'}`} />
))}
</div>
<div className="flex gap-3 px-6 pb-10">
{!last && (
<button type="button" onClick={onDone} className="flex-1 rounded-2xl border border-white/25 py-3.5 text-sm font-semibold text-white/85">
رد کردن
</button>
)}
<button
type="button"
onClick={last ? onDone : () => setStep((s) => s + 1)}
style={{ color: slide.bg }}
className="flex flex-[2] items-center justify-center gap-2 rounded-2xl bg-white py-3.5 font-black shadow-lg shadow-black/20 active:scale-95"
>
{last ? <>بزن بریم! <Sparkles size={18} /></> : <>بعدی <ChevronLeft size={18} /></>}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,283 @@
import { useState, type ReactNode } from 'react';
import {
Bell, CalendarDays, Check, ChevronLeft, Coins, Flame, Gift, GraduationCap, LifeBuoy, LogOut,
Pencil, Smartphone, Sparkles, Star, Trophy, Users, Volume2, VolumeX,
} from 'lucide-react';
import { api } from '@/api';
import { useAppData } from '@/state/AppData';
import { askStore } from '@/state/askStore';
import { useNav } from '@/nav/Navigator';
import type { Route } from '@/nav/routes';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { Sheet } from '@/components/Sheet';
import { useToast } from '@/components/Toast';
import { AgePicker, ageFromBirthYear, birthYearFromAge } from '@/components/AgePicker';
import { InlineError, PrimaryButton, Screen, SecondaryButton } from '@/components/ui';
import { friendlyError } from '@/lib/errors';
import { setSoundEnabled, soundEnabled } from '@/lib/sound';
import { toFa } from '@/lib/format';
type Editing = 'name' | 'grade' | 'age' | 'logout' | null;
/** «من»: پروفایل، تنظیمات و میان‌بُر به بخش‌های فرعی. */
export function ProfileScreen({ onSignedOut }: { onSignedOut: () => void }) {
const { profile, gradeTitle, summary, companion } = useAppData();
const nav = useNav();
const [editing, setEditing] = useState<Editing>(null);
const [sound, setSound] = useState(soundEnabled());
const age = ageFromBirthYear(profile.birthYear);
const go = (route: Route) => () => nav.push(route);
return (
<Screen>
<div className="flex-1 overflow-y-auto">
<header className="relative overflow-hidden rounded-b-[32px] bg-indigo px-5 pb-6 pt-safe text-white">
<div className="pointer-events-none absolute -left-12 -top-12 h-40 w-40 rounded-full bg-white/10" />
<div className="relative flex items-center gap-4 pt-8">
<div className="rounded-3xl bg-white/15 p-2">
<SeyarMascot char={companion} mood="happy" size={72} />
</div>
<div className="min-w-0 flex-1">
<button type="button" onClick={() => setEditing('name')} className="flex items-center gap-2 text-xl font-black">
{profile.name || 'بی‌نام'} <Pencil size={14} className="text-white/70" />
</button>
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs text-white/80">
{gradeTitle && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{gradeTitle}</span>}
{age && <span className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(age)} ساله</span>}
{profile.isGuest ? (
<span className="rounded-full bg-[#FFD23F] px-2.5 py-0.5 font-bold text-ink">مهمان</span>
) : (
profile.phone && <span dir="ltr" className="rounded-full bg-white/15 px-2.5 py-0.5">{toFa(profile.phone)}</span>
)}
</div>
</div>
</div>
<div className="relative mt-5 grid grid-cols-3 gap-2">
{[
{ icon: Star, value: summary?.xp ?? 0, label: 'امتیاز' },
{ icon: Coins, value: summary?.coins ?? 0, label: 'سکه' },
{ icon: Flame, value: summary?.streak.longest ?? 0, label: 'بیشترین زنجیره' },
].map(({ icon: Icon, value, label }) => (
<div key={label} className="flex flex-col items-center rounded-2xl bg-white/12 py-2.5">
<Icon size={16} className="text-white/80" />
<span className="mt-1 text-base font-black">{toFa(value)}</span>
<span className="text-[10px] text-white/70">{label}</span>
</div>
))}
</div>
</header>
<div className="flex flex-col gap-4 px-4 py-5">
{profile.isGuest && (
<button
type="button"
onClick={go({ name: 'register' })}
className="flex items-center gap-3 rounded-3xl bg-[#FFF8E1] p-4 text-right ring-2 ring-[#FFD23F]"
>
<Smartphone size={22} className="flex-shrink-0 text-gold" />
<div className="flex-1">
<div className="text-sm font-black text-ink">شماره‌ت رو ثبت کن</div>
<div className="mt-0.5 text-xs leading-6 text-body">تا اگه گوشیت عوض شد، امتیازها و پیشرفتت گم نشه.</div>
</div>
<ChevronLeft size={18} className="text-muted" />
</button>
)}
<Menu>
<Row icon={GraduationCap} label="پایه‌ی تحصیلی" value={gradeTitle} onClick={() => setEditing('grade')} />
<Row icon={CalendarDays} label="سن" value={age ? `${toFa(age)} سال` : 'وارد نشده'} onClick={() => setEditing('age')} />
<Row icon={Sparkles} label="همراهِ من" onClick={go({ name: 'characters' })} />
</Menu>
<Menu>
<Row icon={Flame} label="تقویمِ زنجیره" onClick={go({ name: 'streak' })} />
<Row icon={Trophy} label="جدولِ امتیازها" onClick={go({ name: 'leaderboard' })} />
<Row icon={Bell} label="اعلان‌ها" onClick={go({ name: 'notifications' })} />
</Menu>
<Menu>
{!profile.isGuest && <Row icon={Gift} label="معرفی به دوستان" onClick={go({ name: 'invite' })} />}
{!profile.isGuest && <Row icon={Users} label="بخشِ والدین" onClick={go({ name: 'family' })} />}
<Row icon={LifeBuoy} label="پشتیبانی و گزارشِ مشکل" onClick={go({ name: 'support' })} />
<Row
icon={sound ? Volume2 : VolumeX}
label="صداها"
value={sound ? 'روشن' : 'خاموش'}
onClick={() => {
setSoundEnabled(!sound);
setSound(!sound);
}}
/>
</Menu>
<button type="button" onClick={() => setEditing('logout')} className="flex items-center justify-center gap-2 py-3 text-sm font-bold text-red-500">
<LogOut size={16} /> خروج از حساب
</button>
</div>
</div>
<NameSheet open={editing === 'name'} onClose={() => setEditing(null)} />
<GradeSheet open={editing === 'grade'} onClose={() => setEditing(null)} />
<AgeSheet open={editing === 'age'} onClose={() => setEditing(null)} />
<LogoutSheet open={editing === 'logout'} onClose={() => setEditing(null)} onSignedOut={onSignedOut} />
</Screen>
);
}
function Menu({ children }: { children: ReactNode }) {
return <div className="divide-y divide-line overflow-hidden rounded-3xl border border-line bg-white">{children}</div>;
}
function Row({ icon: Icon, label, value, onClick }: { icon: typeof Bell; label: string; value?: string; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className="flex w-full items-center gap-3 px-4 py-3.5 text-right active:bg-soft">
<Icon size={18} className="flex-shrink-0 text-indigo" />
<span className="flex-1 text-sm font-bold text-ink">{label}</span>
{value && <span className="text-xs text-muted">{value}</span>}
<ChevronLeft size={16} className="text-faint" />
</button>
);
}
function NameSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, setProfile } = useAppData();
const toast = useToast();
const [name, setName] = useState(profile.name);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
async function save() {
if (!name.trim()) return;
setSaving(true);
setError('');
try {
setProfile(await api.profile.update({ name: name.trim() }));
toast('اسمت عوض شد', 'success');
onClose();
} catch (err) {
setError(friendlyError(err));
} finally {
setSaving(false);
}
}
return (
<Sheet open={open} onClose={onClose} title="اسمت">
<input
value={name}
onChange={(e) => setName(e.target.value.slice(0, 40))}
className="mb-4 w-full rounded-2xl border-2 border-line px-4 py-3.5 text-sm outline-none focus:border-coral"
/>
{error && <div className="mb-4"><InlineError>{error}</InlineError></div>}
<PrimaryButton loading={saving} disabled={!name.trim()} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet>
);
}
function GradeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, grades, setProfile, refreshHome, refreshSummary } = useAppData();
const toast = useToast();
const [saving, setSaving] = useState<number | null>(null);
async function choose(gradeId: number) {
if (gradeId === profile.gradeId) return onClose();
setSaving(gradeId);
try {
await api.profile.setGrade(gradeId);
setProfile({ ...profile, gradeId });
askStore.reset(null);
await Promise.all([refreshHome(), refreshSummary()]);
toast('پایه‌ت عوض شد', 'success');
onClose();
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setSaving(null);
}
}
return (
<Sheet open={open} onClose={onClose} title="پایه‌ی تحصیلی">
<p className="mb-3 text-xs leading-6 text-muted">پیشرفتِ هر پایه جدا نگه داشته می‌شه؛ با برگشتن، سرِ جاشه.</p>
<div className="grid grid-cols-2 gap-2.5">
{grades.map((g) => {
const on = g.id === profile.gradeId;
return (
<button
key={g.id}
type="button"
disabled={saving !== null}
onClick={() => void choose(g.id)}
className={`flex items-center justify-between rounded-2xl border-2 px-3.5 py-3 text-sm font-bold ${on ? 'border-coral bg-coral text-white' : 'border-line text-body'}`}
>
{g.title}
{on && <Check size={16} strokeWidth={3} />}
{saving === g.id && <span className="h-4 w-4 animate-spin rounded-full border-2 border-coral border-t-transparent" />}
</button>
);
})}
</div>
</Sheet>
);
}
function AgeSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { profile, setProfile } = useAppData();
const toast = useToast();
const [age, setAge] = useState<number | null>(ageFromBirthYear(profile.birthYear));
const [saving, setSaving] = useState(false);
async function save() {
if (!age) return;
setSaving(true);
try {
setProfile(await api.profile.update({ birthYear: birthYearFromAge(age) }));
toast('ذخیره شد', 'success');
onClose();
} catch (err) {
toast(friendlyError(err), 'error');
} finally {
setSaving(false);
}
}
return (
<Sheet open={open} onClose={onClose} title="چند سالته؟">
<div className="mb-5">
<AgePicker value={age} onChange={setAge} />
</div>
<PrimaryButton loading={saving} disabled={!age} onClick={() => void save()}>ذخیره</PrimaryButton>
</Sheet>
);
}
function LogoutSheet({ open, onClose, onSignedOut }: { open: boolean; onClose: () => void; onSignedOut: () => void }) {
const { profile } = useAppData();
const [busy, setBusy] = useState(false);
async function logout() {
setBusy(true);
try {
await api.auth.logout();
} catch {
/* حافظه در هر صورت پاک شده؛ کاربر بیرون است */
}
askStore.clear();
onSignedOut();
}
return (
<Sheet open={open} onClose={onClose} title="از حسابت خارج بشی؟">
<p className="mb-5 text-sm leading-7 text-muted">
{profile.isGuest
? 'تو مهمانی: با همین گوشی و مرورگر، «فعلاً مهمان باشم» دوباره تو رو به همین پیشرفت برمی‌گردونه، ولی از گوشیِ دیگه نه. برای امن‌تر شدن، اول شماره‌ت رو ثبت کن.'
: 'هر وقت خواستی با همین شماره دوباره وارد شو؛ پیشرفتت سرِ جاشه.'}
</p>
<div className="flex flex-col gap-2.5">
<SecondaryButton onClick={onClose}>نه، می‌مونم</SecondaryButton>
<PrimaryButton color="#ef4444" loading={busy} onClick={() => void logout()}>خروج</PrimaryButton>
</div>
</Sheet>
);
}

Some files were not shown because too many files have changed in this diff Show More