1 Commits

Author SHA1 Message Date
7dd457d2fc expose the fields and endpoint the screens were missing
بعد از merge شدنِ PR اول، صفحه‌های اپ با API مقایسه شدند و چند چیز کم بود.
همه‌ی تغییراتِ بک‌اند افزایشی‌اند، پس کدِ فعلی نمی‌شکند — این فقط دسترسی به
آن‌ها را اضافه می‌کند.

grades() حالا lessonCount دارد: فهرستِ پایه‌ها تا دوازدهم می‌رود ولی محتوا
تا نهم است، و بدونِ این عدد کاربر پایه‌ی خالی را انتخاب می‌کند و صفحه‌ی
خالی می‌بیند بی‌آنکه بفهمد چرا.

home() در هر مبحث icon و color و در هر فصل blurb می‌دهد، تا رنگ‌ها لازم
نباشد در کد هاردکد بمانند.

nodes.lessons(nodeId) تازه است. home() فقط شناسه‌ی درس‌ها را می‌دهد، ولی
کارتِ نقشه‌ی مهارت عنوان، نوع (آزمون/ویدیو)، زیرنویس («۵ سوال» / «۱۲
دقیقه») و «انجام شد» می‌خواهد — بدونِ این، برای هر کارت یک درخواستِ intro
جدا لازم بود.

lessons.intro() متن را سه‌تکه می‌دهد: introText (توضیح)، example، و steps
(حلِ گام‌به‌گام)؛ به‌علاوه kind و videoUrl و durationMinutes.

profile.update({ age }) هم پذیرفته می‌شود چون صفحه‌ی ثبت‌نام سن می‌پرسد؛
سرور خودش به سالِ تولدِ شمسی تبدیل می‌کند.

هنوز هیچ فایلِ موجودی دست نخورده و پکیجی اضافه نشده. هر ۲۰ صدا در برابرِ
یک بک‌اندِ در حالِ اجرا تست شد.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 01:50:04 +03:30
185 changed files with 12186 additions and 13827 deletions

View File

@@ -1,8 +0,0 @@
# حالتِ توسعه (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

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# این فایل فقط نمونه است. اگر بک‌اندِ محلی دارید، آن را به .env کپی کنید:
# cp .env.example .env
#
# اگر هیچ کاری نکنید هم اپ کار می‌کند و به بک‌اندِ مشترک وصل می‌شود:
# https://api.math.tadrisino2.ir/api
#
# فقط وقتی بک‌اند را روی ماشینِ خودتان بالا آورده‌اید این را باز کنید:
# VITE_API_BASE_URL=http://127.0.0.1:8000/api
# هیچ کلید یا رمزی اینجا نگذارید: هر چیزی که با VITE_ شروع شود داخلِ باندلِ
# نهایی می‌رود و کاربر می‌تواند ببیندش.

View File

@@ -1,8 +0,0 @@
# آدرسِ 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,11 +1,6 @@
node_modules node_modules
dist
# تنظیماتِ شخصیِ هر توسعه‌دهنده — کامیت نشوند # متغیرهای محلی — هیچ‌وقت کامیت نشوند
.env .env
.env.local .env.local
.env.*.local dist
dist.next
dist.prev
.deploy-backups
tsconfig.tsbuildinfo

View File

@@ -1,3 +0,0 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

3
ATTRIBUTIONS.md Normal file
View File

@@ -0,0 +1,3 @@
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).

125
README.md
View File

@@ -1,125 +0,0 @@
# تدریسینو — اپِ ریاضی (فرانت‌اند)
اپِ وبِ موبایل‌محورِ **تدریسینو**، آموزشِ ریاضی به سبکِ دولینگو برای پایه‌ی سوم تا دوازدهم: نقشه‌ای از جزیره‌ها برای هر
کتاب، آزمون‌های کوتاه از مسئله‌های خودِ کتابِ درسی، دستیارِ هوشِ مصنوعی، همراه‌ها، زنجیره‌ی روزانه و اشتراک. همین
مخزن پوسته‌ی اندرویدِ اپ را هم دارد ([`android/`](android/README.md)).
بک‌اند مخزنِ جداگانه‌ای است ([`Tadrisino/MathApp`](https://gitea.tadrisino2.ir/Tadrisino/MathApp)) و قراردادِ API
اسکیمای OpenAPIِ همان‌جاست. این اپ تنها کلاینتِ آن API است.
| | |
|---|---|
| **سایتِ زنده** | `https://math.tadrisino2.ir` · API: `https://mathapi.tadrisino2.ir` |
| **فناوری** | React 18 · TypeScript (strict) · Vite 6 · Tailwind 4 · Motion |
| **وابستگی‌ها** | عمداً کم: بی‌کتابخانه‌ی router و state؛ ناوبری و داده‌ی مشترک در خودِ کد |
| **اندروید** | پوسته‌ی WebView روی همین سایت، با پرداختِ درون‌برنامه‌ایِ کافه‌بازار (Poolakey)؛ بسته‌ی `com.tadrisino.math.app` |
---
## راه‌اندازی
```bash
npm install
npm run dev # http://localhost:5173
```
در حالتِ توسعه، Vite درخواست‌های `/api` را به بک‌اند پاس می‌دهد، پس 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 |
---
## ساختار
```
src/
config.ts آدرسِ API (بخشِ پایین)
api/ تنها درِ ارتباط با سرور — هیچ جای دیگر fetch نمی‌زند
http.ts توکن، timeout، تکرارِ GET، refreshِ تک‌نسخه‌ای، SSE، Idempotency-Key
index.ts یک تابع برای هر اندپوینت
types.ts شکلِ پاسخ‌ها، از روی پاسخِ واقعیِ سرور
app/ App (جریانِ ورود) و MainApp (تب‌ها و صفحه‌ها)
nav/ ناوبریِ پشته‌ای، هماهنگ با دکمه‌ی back گوشی
state/ داده‌ی مشترک (پروفایل، نقشه، کتابِ انتخاب‌شده، همراه) و گفتگوی دستیار
screens/ یک فایل برای هر صفحه
components/ اجزای مشترک: مسیرِ جزیره‌ها، نوارِ کتاب‌ها، برگه‌ی پایین، بازخورد، …
mascots/ همراه‌ها (سیگما، دلتا، پی، امگا): SVG از کد، شش حالتِ چهره
diagram/ شکلِ درس و جوابِ دستیار: توصیفِ JSON از بک‌اند → SVG؛ همه‌ی مختصات این‌جا حساب می‌شود
lib/ اعدادِ فارسی، تاریخِ شمسی، MathText، پیامِ خطا، صدا، پلِ کافه‌بازار، روش‌های پرداخت
android/ پوسته‌ی اندروید (README خودش)
pay.html · src/pay.ts صفحه‌ی واسطِ پرداختِ زرین‌پال برای اپِ اندرویدِ فعلی
```
قاعده‌ها:
- **کلاینت را از حافظه ننویسید.** قرارداد را از اسکیمای بک‌اند بخوانید
(`python manage.py spectacular --file schema.yaml --format openapi`) و هر تغییرِ `types.ts` را با پاسخِ واقعیِ سرور بسنجید.
- **صفحه‌ها هرگز مستقیم سرور را صدا نمی‌زنند:** همیشه `screens/* → api/index.ts → api/http.ts`.
- **اعداد:** برای نمایش `toFa`، برای ورودی `digitsOnly` (کیبوردِ فارسی ارقامِ `۰-۹` می‌فرستد و `\d` در JS آن‌ها را نمی‌شناسد).
- **رنگ‌ها** در `src/styles/index.css` (`@theme`) تعریف شده‌اند: `bg-coral`، `text-ink`، …
- **خطا:** پیامِ کاربر همیشه از `friendlyError` می‌آید، نه متنِ خامِ سرور.
- **بازشدنِ فوری:** کاربرِ واردشده خانه را از عکسِ روی گوشی می‌بیند (`state/homeSnapshot.ts`: پروفایل، پایه‌ها، نقشه،
آمار) و همان درخواست‌های بوت پشتِ صحنه تازه‌اش می‌کنند. عکس با پاک شدنِ توکن و ورودِ تازه پاک می‌شود، با بیلدِ دیگر
یا کهنه‌تر از ۷ روز خوانده نمی‌شود، و درستی (قفل، اشتراک، قلب) همیشه با سرور است. داده‌ی تازه‌ای که در خانه دیده
می‌شود باید در `AppData` بماند تا در عکس هم برود.
- **متنِ فارسی** بی‌اعراب و بی «ی»ِ اضافه‌ی نوشته‌شده («پایه‌ی سوم») است (تصمیمِ شرکت)؛ تنوین («قبلاً») می‌ماند. متنِ تازه
را همین‌طور بنویسید. متنِ سرور را بک‌اند هنگامِ ساختنِ پاسخ پاک می‌کند و `plainPersian` در `MathText` همان کار را برای
پاسخِ زنده‌ی دستیار. `lib/plainMath.ts` هر LaTeX یا markdownی را که برسد ساده می‌کند و باید با `content/plain_math.py`ِ
بک‌اند هم‌رفتار بماند.
---
## آدرسِ بک‌اند و عوض کردنِ دامنه
دامنه هیچ‌جای کد نیامده. آدرسِ API به این ترتیب خوانده می‌شود (`src/config.ts`):
1. **`dist/config.js`** (از `public/config.js`) — در زمانِ اجرا؛ بعد از build هم روی سرور ویرایش‌شدنی است.
2. **`VITE_API_BASE_URL`** — در زمانِ build، از `.env.production`.
3. **`/api`** — هم‌دامنه، وقتی nginxِ دامنه‌ی فرانت مسیرِ `/api` را به بک‌اند پراکسی کند.
برای جابه‌جاییِ دامنه:
- **سریع، بدونِ build:** روی سرور در `dist/config.js` بنویسید `apiBaseUrl: "https://<دامنه‌ی تازه>/api"` و کشِ آروان را
برای همین فایل پاک کنید.
- **ماندگار:** خطِ `VITE_API_BASE_URL` در `.env.production` را عوض کنید و build بگیرید.
- **سمتِ بک‌اند** دامنه‌ی تازه‌ی فرانت باید مجاز باشد (`FRONTEND_URL`)، و در CSPِ nginx (`connect-src`) دامنه‌ی تازه‌ی API
بیاید. رویه‌ی کامل: `docs/operations.md`ِ بک‌اند، بخشِ «عوض کردنِ دامنه».
## روش‌های پرداخت
برگه‌ی پرداخت روش‌ها را از `paymentMethods` در `dist/config.js` می‌خواند (`src/lib/paymentMethods.ts`)، جدا برای مرورگر
(`web`) و داخلِ اپِ اندروید (`app`)؛ مقدارها `"zarinpal"` و `"bazaar"`. پیش‌فرض: مرورگر فقط زرین‌پال، اپ هر دو. نسخه‌ای که
فقط در کافه‌بازار است: `paymentMethods: { app: ["bazaar"] }`؛ build لازم نیست، فقط کشِ آروانِ این فایل را پاک کنید.
---
## انتشار
روی سرور (`~/mathapp_frontend_new`، که nginxِ `math.tadrisino2.ir` از `dist/`ش سرو می‌کند):
```bash
cd ~/mathapp_frontend_new && ./deploy.sh # fetch، npm ci، type-check، build در dist.next و جابه‌جاییِ بی‌وقفه
```
buildِ قبلی در `dist.prev` می‌ماند (برگشت: `rm -rf dist && mv dist.prev dist`) و `dist/config.js`ِ ویرایش‌شده روی سرور به
buildِ تازه منتقل می‌شود. سرور مشترکِ شرکت است: پیش از هر کار آن‌جا قواعدِ `CLAUDE.md`ِ بک‌اند (بخشِ «Working on the
servers») را بخوانید.
جای دیگر: `npm ci && npm run build` و سرو کردنِ `dist/`. اپ مسیرِ جدا (route) ندارد و همه‌چیز از `/` بار می‌شود؛ فقط
`index.html` و `config.js` نباید طولانی کش شوند (باقیِ فایل‌ها نامِ hashدار دارند). درگاهِ پرداخت کاربر را به
`/?orderId=…&status=…` برمی‌گرداند و لینکِ دعوت `/?ref=CODE` است؛ هر دو را خودِ اپ می‌خواند.
CSPِ سایت در nginx است (`ops/nginx/zareian-front-headers.conf` در مخزنِ بک‌اند). اگر اسکریپتِ درون‌خطیِ `index.html` عوض
شود، هشِ تازه‌اش باید در همان فایل بنشیند، وگرنه مرورگر آن را اجرا نمی‌کند.

8
android/.gitignore vendored
View File

@@ -1,8 +0,0 @@
.gradle/
build/
app/build/
local.properties
.idea/
*.iml
*.jks
*.keystore

View File

@@ -1,88 +0,0 @@
# اپِ اندرویدِ تدریسینو (پوسته‌ی WebView + پرداختِ بازار)
همان سایتِ `math.tadrisino2.ir` داخلِ یک WebView. تنها کدِ بومی پرداختِ درون‌برنامه‌ایِ
کافه‌بازار است (کتابخانه‌ی Poolakey)، چون بازار برای اپی که در آن منتشر می‌شود پرداختِ
محصولِ دیجیتال را فقط از راهِ خودش می‌پذیرد. باقیِ اپ همان فرانتِ وب است و با دیپلویِ
فرانت به‌روز می‌شود، بی‌آن‌که نسخه‌ی تازه‌ی اپ لازم باشد.
> **ساخت و امضا با هم‌تیمی است (MH-W90، m.hassanabadi)** با روشِ امضای خودش (`my-release-key.keystore` +
> propertyهای Gradle)؛ تصمیمِ مالک این است که همان روش بماند. پیش از دست زدن به امضا از مالک بپرس و هرگز
> keystore را commit نکن. `applicationId` واقعی `com.tadrisino.math.app` است (از APKِ ساخته‌شده با aapt2).
> هیچ خریدِ واقعیِ بازار هنوز آزموده نشده.
## نسخه‌ی بعدیِ APK — چه چیزی در کد هست و در اپِ نصب‌شده نیست
این‌ها در `MainActivity.kt` نوشته شده‌اند (۱۴۰۵-۰۷، `468f5b2` و `737414f`) ولی هیچ‌کدام build نشده‌اند؛ اپی که الان روی
گوشی‌هاست بدونِ آن‌هاست. فرانت با هر دو نسخه کار می‌کند (اگر پل نبود، راهِ قدیم را می‌رود).
| تغییر | چرا | بعد از build روی گوشی بسنج |
|---|---|---|
| `TadrisinoAndroid.vibrate(patternJson)` با Vibratorِ گوشی | `navigator.vibrate` در WebView همیشه کار نمی‌کند؛ مالک گفت اپ لرزش ندارد | انتخابِ گزینه‌ی آزمون، جوابِ درست/غلط، پایانِ جزیره و صفحه‌ی «روزِ پیاپی» باید بلرزند؛ با خاموش کردنِ «صداها» در پروفایل نه |
| `mediaPlaybackRequiresUserGesture = false` | صداهای رابط بعد از پاسخِ سرور پخش می‌شوند، نه در لحظه‌ی لمس | صدای جوابِ درست/غلط و پایانِ تمرین |
| `isPaymentPage` + `paysInApp()` | درگاهِ زرین‌پال داخلِ خودِ اپ باز می‌شود. اپِ فعلی آن را به مرورگرِ گوشی می‌دهد که Referer نمی‌فرستد و زرین‌پال بی‌Referer «دسترسی از این دامنه مجاز نمی‌باشد» می‌دهد؛ فعلاً فرانت از راهِ `pay.html` (با `http://`) دورش می‌زند | «پرداخت» با کارتِ بانکی: صفحه‌ی زرین‌پال و بانک داخلِ اپ، و بعد از پرداخت برگشت به اپ با پیامِ «پرداخت موفق بود» (PaymentResult). دکمه‌ی برگشت وسطِ صفحه‌ی بانک چه می‌کند را هم ببین |
| اسپلشِ یک‌مرحله‌ای: `installSplashScreen()` + `setKeepOnScreenCondition` + `TadrisinoAndroid.appReady()` (`446cf36`، `ab02bb4`) | اپِ فعلی سه تکه می‌شود: اسپلشِ اندروید ← WebViewِ سفیدِ خالی ← اسپلشِ وب (آیکون + نام) ← خانه. با APKِ تازه اسپلشِ بومی تا اولین صفحه‌ی واقعیِ وب می‌ماند و مستقیم می‌رود روی همان صفحه؛ اسپلشِ وب داخلِ اپ نامرئی است (در APKِ قدیمی فقط آیکونِ بی‌نام). فرانت `appReady` را بعد از اولین صفحه‌ی غیر‌boot صدا می‌زند و APKِ قدیمی آن را ندارد، پس بی‌اثر است | روی گوشی: باز کردنِ اپ با اینترنت باید آیکون ← خانه باشد، بی‌سفیدی و بی‌تصویرِ دوم؛ اندازه‌ی آیکون روی اندروید ۱۲+ (`drawable/splash_icon.xml`: ۲۸۸dp، حاشیه ۲۲٪) |
| خریدِ اشتراک از کافه‌بازار: کلیدِ RSA، `bazaarBuy` (`subscribeProduct`)، قیمتِ پیشخان (`bazaarPrices`)، «تخفیف پویا»؛ `versionCode` ۲ | بی‌این‌ها از بازار هیچ خریدی ممکن نیست (APKهای قبلی کلید نداشتند) | داخلِ اپ ← فروشگاه ← اشتراکِ سه‌ماهه: «کارتِ بانکی» پیش‌فرض، کاشیِ «کافه‌بازار» فعال با قیمتِ بازار زیرِ آن؛ خرید با حسابِ تست، برگشت با «اشتراکت فعاله»؛ در `/admin/payment/order/` سفارشِ بازار «پرداخت‌شده». یک بار هم «انصراف» در صفحه‌ی بازار |
| صفحه‌ی بی‌اتصال: `onReceivedError`/`onReceivedHttpError` ← همان تصویر + «اینترنت قطع است / کند است؛ در حال اتصال…»، تلاشِ دوباره هر ۵ ثانیه، و مجوزِ `ACCESS_NETWORK_STATE` | کاربر هرگز صفحه‌ی خطای WebView یا متنِ فنی نمی‌بیند. اتصالِ کند (بی‌خطا) عمداً هیچ متنِ اضافه نمی‌آورد و روی اسپلش می‌ماند | هواپیما روشن ← باز کردنِ اپ ← خاموش کردن: باید خودش وصل شود. سرورِ ۵۰۲ (مثلاً nginx خاموش) هم همین صفحه. اگر صفحه لود شد ولی ۱۵ ثانیه `appReady` نیامد، لاگِ ERROR و رهاشدنِ اسپلش (باگِ JS) |
**اسپلش و بی‌اینترنتی همه کارِ APK است (مالک، ۲۰۲۶-۱۰-۱۱، با اپِ نصب‌شده امتحان شد).** چیزی که روی گوشی دیده می‌شود
از وب قابل رفع نیست، چون وب هنوز بار نشده یا اصلاً نمی‌رسد:
- اپِ نصب‌شده: اسپلشِ اندروید ← صفحه‌ی سفید ← اسپلشِ وب ← خانه. با فیلترشکن (یا هر وقت `math.tadrisino2.ir` نرسد)
فقط **سفید** می‌ماند و هیچ پیامی نیست، چون HTML هرگز نرسیده و هیچ کدِ ما اجرا نمی‌شود. علتِ دقیقِ آن سفیدی (HTML نرسیده
یا API نرسیده) روی فیلترشکن بازتولید **نشد**؛ APKِ تازه هر دو را با همان صفحه‌ی بی‌اتصال می‌پوشاند.
- APKِ تازه (دو ردیفِ بالا) باید: ۱) با اینترنت، اسپلشِ بومی مستقیم به خانه برود و وسطش هیچ‌چیز نیفتد؛ ۲) بی‌اینترنت یا
کند، **زیرِ همان تصویرِ ثابت** بگوید «اینترنت قطع است / کند است»، نه سفید و نه متنِ خطا.
- آزمونِ پذیرش روی گوشی، پیش از انتشار: اینترنتِ عادی؛ هواپیما؛ فیلترشکنِ روشن (همان که سفید می‌داد)؛ اینترنتِ خیلی کند.
در هیچ‌کدام نباید سفیدِ خالی یا صفحه‌ی خطای WebView دیده شود.
پیشنهادهایی که هنوز نوشته نشده‌اند:
- **«برای خروج دوباره بزن»**: دکمه‌ی برگشتِ گوشی حالا قدم‌به‌قدم در خودِ وب عقب می‌رود (`src/nav/Navigator.tsx`) و
از خانه یک‌راست بیرون می‌رود. اگر تأییدِ خروج خواستید، در `handleOnBackPressed` وقتی `!webView.canGoBack()` یک
Toast بدهید و فقط برگشتِ دوم در دو ثانیه `finish()` کند. در وب شدنی نیست (کروم ورودیِ history بی‌لمس را رد می‌کند).
- `versionCode`/`versionName` را برای هر نسخه‌ای که منتشر می‌شود بالا ببرید؛ الان ۲ و 1.1.0 است (بازار فقط `versionCode`ِ بزرگ‌تر را می‌پذیرد).
قانونِ بازار: داخلِ اپِ بازار حالا پرداخت با درگاه هم پیشنهاد می‌شود (خواستِ مالک). بازار پرداختِ محتوای دیجیتال بیرون از
پرداختِ درون‌برنامه‌ای را محدود می‌کند؛ پیش از انتشار در بازار این را با بازار یا مالک روشن کنید.
ساخت روی مکِ مالک: SDK در `~/Library/Android/sdk`، JDK 17 (`/opt/homebrew/opt/openjdk@17`)، و چون Google Maven از
ایران بی‌VPN ۴۰۴ می‌دهد، `~/.gradle/init.d/google-mirror.gradle` آینه‌ی Aliyun را اضافه می‌کند؛ ساختِ اول ~۹ دقیقه.
## پیش از build — آنچه از پیشخانِ بازار لازم است
| چه | کجا گذاشته می‌شود | وضعیت (۲۰۲۶-۱۰-۱۲) |
|---|---|---|
| اسمِ پکیج | `applicationId` در `app/build.gradle.kts` | `com.tadrisino.math.app`؛ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را زیرِ همین نام شناخت (API، توکنِ ساختگی) |
| کلیدِ RSAِ عمومیِ اپ | `BAZAAR_RSA_KEY` در `app/build.gradle.kts` | گذاشته شد |
| SKUِ هر پلن | پنلِ ادمینِ بک‌اند: `/admin/subscription/plan/` ← «Bazaar sku» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ی سوال فروخته نمی‌شود |
| کلیدِ «تخفیف پویا» | فقط `.env`ِ سرور (`BAZAAR_DYNAMIC_PRICE_KEY`)، **هرگز در اپ** | روی سرور |
فقط اشتراک فروخته می‌شود. قیمت: فرانت `TadrisinoAndroid.bazaarPrices(skusJson)` را صدا می‌زند و قیمتِ پیشخان را روی
کاشیِ بازار نشان می‌دهد. خرید: `TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)` با `subscribeProduct`
(مصرف نمی‌شود، بازار خودش تمدید می‌کند). قیمتِ پیشخان باید با قیمتِ همان پلن در ادمین یکی باشد؛ فقط وقتی کدِ تخفیف
خورده، سرور قیمتِ کم‌شده را امضا می‌کند.
`compileDebugKotlin` سبز است؛ هیچ خریدی روی گوشی آزموده نشده.
## build
1. Android Studio ← Open ← همین پوشه‌ی `android/`. Studio خودش Gradle wrapper را می‌سازد.
2. `Build ← Generate Signed App Bundle / APK`، با کلیدِ امضای شرکت. **کلیدِ امضا را گم نکنید**:
بدونِ همان کلید هیچ نسخه‌ی بعدی روی بازار به‌روزرسانی نمی‌شود.
3. نصب روی گوشی‌ای که کافه‌بازار دارد و با حسابِ تست وارد شده.
## جریانِ خرید (برای عیب‌یابی)
```
فرانت POST /api/subscription/purchase {planId, paymentMethod:"bazaar"} → orderId, bazaarSku
فرانت (اختیاری) POST /api/discount/redeem {code, orderId}
فرانت POST /api/payment/bazaar/orders/{orderId}/start → sku، dynamicPriceToken (اگر تخفیف خورده)
فرانت TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken)
اپ Poolakey.subscribeProduct(sku, payload=orderId) → purchaseToken
اپ رویدادِ «tadrisino:bazaar» {orderId, purchaseToken}
فرانت POST /api/payment/bazaar/verify {orderId, purchaseToken} ← سرور از بازار می‌پرسد و اشتراک را فعال می‌کند
```
اگر پول رفت ولی تأییدِ سرور نرسید، کاربر دوباره «پرداخت» می‌زند: فرانت همان سفارش را می‌فرستد
و اپ توکنِ همان سفارش را از `getSubscribedProducts` پیدا می‌کند.
قرارداد سمتِ وب: `src/lib/bazaar.ts`. سمتِ سرور: `payment/bazaar.py` و `docs/operations.md`ِ بک‌اند.

View File

@@ -1,113 +0,0 @@
// plugins {
// id("com.android.application")
// id("org.jetbrains.kotlin.android")
// }
// android {
// // ⚠️ اسمِ پکیج باید همانی باشد که در پیشخانِ بازار ثبت است. com.tadrisino.app را بازار
// // برای حسابِ شرکت «Product is not found» جواب داد (یعنی هست)، ولی سنیور تأیید نکرده
// // که همان اپِ ریاضی است (CLAUDE.mdِ بک‌اند، ۲۰۲۶-۰۹-۳۰).
// namespace = "com.tadrisino.app"
// compileSdk = 34
// defaultConfig {
// applicationId = "com.tadrisino.math.app"
// minSdk = 23
// targetSdk = 34
// versionCode = 1
// versionName = "1.0.0"
// // آدرسِ همان فرانتِ وب. دامنه عوض شود، فقط همین خط.
// buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// // کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (بخشِ «پرداخت درون‌برنامه‌ای»). کلیدِ عمومی
// // است و در اپ می‌نشیند؛ رمز نیست. تا پر نشود، اپ خرید را شروع نمی‌کند.
// buildConfigField("String", "BAZAAR_RSA_KEY", "\"\"")
// }
// buildFeatures {
// buildConfig = true
// }
// buildTypes {
// release {
// isMinifyEnabled = false
// }
// }
// compileOptions {
// sourceCompatibility = JavaVersion.VERSION_17
// targetCompatibility = JavaVersion.VERSION_17
// }
// kotlinOptions {
// jvmTarget = "17"
// }
// }
// dependencies {
// implementation("androidx.appcompat:appcompat:1.7.0")
// implementation("androidx.activity:activity-ktx:1.9.2")
// implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
// }
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
// Package name must match the one registered on Cafe Bazaar.
// Confirm the final applicationId before publishing.
namespace = "com.tadrisino.app"
compileSdk = 34
defaultConfig {
applicationId = "com.tadrisino.math.app"
minSdk = 23
targetSdk = 34
// بازار نسخه‌ی تازه را فقط با versionCodeِ بزرگ‌تر می‌پذیرد؛ هر build که منتشر می‌شود یکی بالا.
versionCode = 2
versionName = "1.1.0"
// URL of the web frontend.
buildConfigField("String", "WEB_URL", "\"https://math.tadrisino2.ir/\"")
// کلیدِ RSAِ عمومیِ اپ از پیشخانِ بازار (پرداخت درون‌برنامه‌ای)؛ سینیور، ۲۰۲۶-۱۰-۱۲. عمومی است و در APK
// می‌رود؛ Poolakey با آن امضای خرید را روی گوشی می‌سنجد. سنجشِ اصلی سمتِ سرور است.
buildConfigField("String", "BAZAAR_RSA_KEY", "\"MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwC9EBw0RW6jeTdHkrJwfB/mX+klpjpVXVb2stpGDcixeqHiLE+/Iow+PiMbZEQfxZDuED6ELvZlzQX+ALOlGRPxaa1ctl6/Uy6wGaPXhGPDpZL6jPrXV88mjJzMcEoB4sf3yNb+6YNKrvKhRyXXR0JdMUmPMqMe8igmQZTv/UF+QEMNqv7lg7qw9Yl+fG802nqjasft7qUPQK+YHJYPS4PJ5Pj/YpTkGDdDmVi7AIMCAwEAAQ==\"")
}
buildFeatures {
buildConfig = true
}
signingConfigs {
create("release") {
storeFile = file("my-release-key.keystore")
storePassword = providers.gradleProperty("MYAPP_STORE_PASSWORD").orNull
keyAlias = "my-key-alias"
keyPassword = providers.gradleProperty("MYAPP_KEY_PASSWORD").orNull
}
}
buildTypes {
release {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.activity:activity-ktx:1.9.2")
// اسپلشِ سیستم تا آماده‌شدنِ اولین صفحه‌ی وب می‌ماند (MainActivity.keepSplash).
implementation("androidx.core:core-splashscreen:1.0.1")
implementation("com.github.cafebazaar.Poolakey:poolakey:2.2.0")
}

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- فقط برای فرقِ «اینترنت قطع است» و «اینترنت کند است» در اسپلشِ بی‌اتصال؛ مجوزِ عادی، بی‌پرسش. -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- برای لرزشِ کوتاهِ جوابِ درست/غلط (navigator.vibrate در WebView). -->
<uses-permission android:name="android.permission.VIBRATE" />
<!-- اندروید ۱۱+: بدونِ این، اپ نمی‌تواند سرویسِ پرداختِ بازار را پیدا کند. -->
<queries>
<package android:name="com.farsitel.bazaar" />
</queries>
<application
android:allowBackup="false"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Tadrisino">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:theme="@style/Theme.Tadrisino.Starting"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -1,400 +0,0 @@
package com.tadrisino.app
import android.annotation.SuppressLint
import android.content.Intent
import android.content.Context
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import android.webkit.JavascriptInterface
import android.webkit.WebResourceResponse
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import ir.cafebazaar.poolakey.Connection
import ir.cafebazaar.poolakey.ConnectionState
import ir.cafebazaar.poolakey.Payment
import ir.cafebazaar.poolakey.config.PaymentConfiguration
import ir.cafebazaar.poolakey.config.SecurityCheck
import ir.cafebazaar.poolakey.exception.BazaarNotFoundException
import ir.cafebazaar.poolakey.exception.BazaarNotSupportedException
import ir.cafebazaar.poolakey.exception.DynamicPriceNotSupportedException
import ir.cafebazaar.poolakey.exception.IAPNotSupportedException
import ir.cafebazaar.poolakey.exception.SubsNotSupportedException
import ir.cafebazaar.poolakey.request.PurchaseRequest
import org.json.JSONArray
import org.json.JSONObject
/**
* همان سایتِ تدریسینو داخلِ WebView؛ تنها کارِ بومی پرداختِ درون‌برنامه‌ایِ بازار است.
*
* قراردادِ پل با `src/lib/bazaar.ts`ِ فرانت:
* JS → اینجا TadrisinoAndroid.bazaarBuy(sku, orderId, dynamicPriceToken) (اشتراک)
* TadrisinoAndroid.bazaarPrices(skusJson) → رویدادِ «tadrisino:bazaar-prices»
* اینجا → JS window رویدادِ «tadrisino:bazaar» با { orderId, purchaseToken } یا
* { orderId, error: "canceled" | "failed" | "unavailable", message }
*
* سمتِ سرور توکن را از خودِ بازار می‌سنجد (`POST /api/payment/bazaar/verify`)، پس این‌جا
* فقط واسطه است و به هیچ نتیجه‌ای اعتماد نمی‌کند.
*
* ⚠️ کامپایل می‌شود (compileDebugKotlin، ۲۰۲۶-۱۰-۱۲) و امضای توابعِ Poolakey 2.2.0 از خودِ کتابخانه خوانده شد،
* ولی روی گوشی اجرا نشده؛ پیش از انتشار یک خریدِ واقعی بزنید.
*/
class MainActivity : AppCompatActivity() {
private lateinit var webView: WebView
/**
* اسپلشِ سیستم تا وقتی true نشده روی صفحه می‌ماند، هرچقدر اتصال کند باشد (خواستِ مالک): فرانت وقتی
* اولین صفحه‌ی واقعی (ورود/خانه) کشیده شد `TadrisinoAndroid.appReady()` را صدا می‌زند.
* فقط دو حالت زودتر رهایش می‌کند: صفحه‌ی اصلی بالا نیامد (آفلاین/خطای سرور → [showOffline]، با همان
* تصویر و یک جمله)، یا صفحه لود شد ولی تا [READY_WATCHDOG_MS] appReady نیامد (خطای JS؛ لاگ می‌شود).
*/
@Volatile private var webReady = false
private val handler = Handler(Looper.getMainLooper())
private lateinit var offlineView: View
private lateinit var offlineText: TextView
private var offlineShown = false
private val retryLoad = object : Runnable {
override fun run() {
if (!offlineShown) return
webView.loadUrl(BuildConfig.WEB_URL)
handler.postDelayed(this, RETRY_MS)
}
}
private fun dp(v: Int): Int = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v.toFloat(), resources.displayMetrics).toInt()
/** همان تصویرِ اسپلشِ سیستم (۲۸۸dp، همان drawable) با یک جمله زیرش؛ جای آیکون با اسپلشِ سیستم یکی است. */
private fun buildOfflineView(): View = FrameLayout(this).apply {
setBackgroundColor(Color.WHITE)
visibility = View.GONE
isClickable = true
addView(ImageView(context).apply { setImageResource(R.drawable.splash_icon) },
FrameLayout.LayoutParams(dp(288), dp(288), Gravity.CENTER))
addView(TextView(context).apply {
offlineText = this
text = "اینترنت قطع است"
setTextColor(Color.parseColor("#1a1a2e"))
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
layoutDirection = View.LAYOUT_DIRECTION_RTL
gravity = Gravity.CENTER
}, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.CENTER)
.apply { topMargin = dp(150) })
}
/** هیچ متنِ فنیِ خطا نشان داده نمی‌شود؛ فقط یکی از دو جمله: «قطع است» (شبکه‌ای نیست) یا «کند است». */
private fun hasInternet(): Boolean {
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
private fun showOffline() {
offlineText.text = if (hasInternet()) "اینترنت کند است؛ در حال اتصال…" else "اینترنت قطع است؛ در حال اتصال…"
if (!offlineShown) {
offlineShown = true
webView.visibility = View.INVISIBLE // صفحه‌ی خطای پیش‌فرضِ WebView دیده نشود
offlineView.visibility = View.VISIBLE
handler.postDelayed(retryLoad, RETRY_MS)
}
webReady = true
}
private fun hideOffline() {
if (!offlineShown) return
offlineShown = false
handler.removeCallbacks(retryLoad)
offlineView.visibility = View.GONE
webView.visibility = View.VISIBLE
}
private var payment: Payment? = null
private var connection: Connection? = null
private val webHost: String = Uri.parse(BuildConfig.WEB_URL).host ?: ""
/** فقط خودِ سایت و فقط با https؛ «http://دامنه‌ی ما» قابلِ دست‌کاریِ شبکه است و نباید پل داشته باشد. */
private fun isOurPage(uri: Uri?): Boolean = uri != null && uri.scheme == "https" && uri.host == webHost
/** API و درگاه: زیردامنه‌های دامنه‌ی خودمان (mathapi…)، زرین‌پال و صفحه‌های بانکیِ شاپرک؛ فقط https. پل این‌جا کار نمی‌کند. */
private fun isPaymentPage(uri: Uri?): Boolean {
val host = uri?.host ?: return false
if (uri.scheme != "https") return false
val ours = webHost.substringAfter('.')
return host == ours || host.endsWith(".$ours") ||
host == "zarinpal.com" || host.endsWith(".zarinpal.com") || host.endsWith(".shaparak.ir")
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen().setKeepOnScreenCondition { !webReady }
super.onCreate(savedInstanceState)
webView = WebView(this)
offlineView = buildOfflineView()
setContentView(FrameLayout(this).apply {
addView(webView)
addView(offlineView)
})
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true // توکنِ ورود در localStorage است
// WebView فقط آدرسِ وب را می‌خواند؛ دسترسی به فایل و content:// لازم نیست.
webView.settings.allowFileAccess = false
webView.settings.allowContentAccess = false
// صداهای کوتاهِ رابط (Web Audio) بعد از پاسخِ سرور پخش می‌شوند، نه درست در لحظه‌ی لمس.
webView.settings.mediaPlaybackRequiresUserGesture = false
webView.webViewClient = object : WebViewClient() {
// صفحه‌ی اصلی بالا نیامد (بی‌اینترنت یا خطای سرور): همان تصویر با «در حال اتصال»، و هر چند ثانیه دوباره.
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: android.webkit.WebResourceError) {
if (request.isForMainFrame) showOffline()
}
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
if (request.isForMainFrame && errorResponse.statusCode >= 500) showOffline()
}
override fun onPageFinished(view: WebView, url: String?) {
if (offlineShown) return // صفحه‌ی خطا هم onPageFinished می‌زند؛ تا بارِ موفق همان تصویر بماند
handler.postDelayed({
if (!webReady) Log.e(TAG, "page loaded but appReady never came in ${READY_WATCHDOG_MS}ms; releasing the splash")
webReady = true
}, READY_WATCHDOG_MS)
}
// هر آدرسی بیرونِ دامنه‌ی خودمان در مرورگرِ گوشی باز می‌شود، نه داخلِ اپ؛ پس پل فقط
// در صفحه‌های خودمان در دسترس است.
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (isOurPage(request.url)) return false
// پرداخت با درگاه داخلِ خودِ اپ می‌ماند: زرین‌پال بی‌Refererِ دامنه‌ی ما صفحه را باز نمی‌کند و مرورگرِ
// بیرونی Referer نمی‌فرستد. بعد از پرداخت، زرین‌پال به سایتِ ما برمی‌گرداند و نتیجه همین‌جا دیده می‌شود.
if (isPaymentPage(request.url)) return false
// فقط http(s) به مرورگرِ گوشی می‌رود؛ intent:، file: و schemeهای سفارشی بی‌صدا بسته می‌شوند
// (پیش‌تر هر آدرسی بیرونِ دامنه به ACTION_VIEW می‌رفت و صفحه می‌توانست اپِ دلخواه باز کند).
if (request.url.scheme == "https" || request.url.scheme == "http") {
startActivity(Intent(Intent.ACTION_VIEW, request.url))
}
return true
}
}
webView.addJavascriptInterface(Bridge(), "TadrisinoAndroid")
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
// اپِ وب برای هر صفحه history.pushState می‌زند؛ دکمه‌ی back همان را برمی‌گرداند.
override fun handleOnBackPressed() {
if (webView.canGoBack()) webView.goBack() else finish()
}
})
connectToBazaar()
if (savedInstanceState == null) webView.loadUrl(BuildConfig.WEB_URL)
}
private fun connectToBazaar() {
if (BuildConfig.BAZAAR_RSA_KEY.isEmpty()) {
Log.e(TAG, "BAZAAR_RSA_KEY is empty; in-app purchase disabled")
return
}
val config = PaymentConfiguration(
localSecurityCheck = SecurityCheck.Enable(rsaPublicKey = BuildConfig.BAZAAR_RSA_KEY),
)
connection?.disconnect()
payment = Payment(context = this, config = config).also { p ->
connection = p.connect {
connectionSucceed { Log.i(TAG, "bazaar connected") }
connectionFailed { Log.e(TAG, "bazaar connection failed", it) }
disconnected { Log.w(TAG, "bazaar disconnected") }
}
}
}
/**
* خریدِ اشتراکِ بازار (subscribeProduct؛ فقط اشتراک فروخته می‌شود و مصرف نمی‌شود، بازار خودش تمدید می‌کند).
* dynamicPriceToken خالی یعنی قیمتِ پیشخان؛ پر یعنی قیمتی که سرور با کلیدِ «تخفیف پویا» امضا کرده
* (بازارِ ۱۳.۳ به بعد).
*/
private fun buy(sku: String, orderId: String, dynamicPriceToken: String) {
val p = payment
if (p == null || connection?.getState() !is ConnectionState.Connected) {
// بازار نصب نیست، هنوز وصل نشده، یا اتصال افتاده؛ برای تلاشِ بعدی دوباره وصل می‌شویم.
connectToBazaar()
send(orderId, error = "unavailable", message = "not_connected")
return
}
val request = PurchaseRequest(
productId = sku, payload = orderId, dynamicPriceToken = dynamicPriceToken.ifEmpty { null },
)
p.subscribeProduct(activityResultRegistry, request) {
purchaseSucceed { send(orderId, token = it.purchaseToken) }
purchaseCanceled { send(orderId, error = "canceled") }
purchaseFailed { failed(sku, orderId, it) }
failedToBeginFlow { failed(sku, orderId, it) }
}
}
/**
* بازارِ نصب‌نشده یا قدیمی (بی پشتیبانیِ اشتراک یا «تخفیف پویا») کاری است که کاربر باید بکند، نه خطای خرید؛
* فرانت برایش پیامِ جدا دارد. بقیه‌ی خطاها ممکن است خریدی باشند که پولش رفته، پس از بازار پرسیده می‌شوند.
*/
private fun failed(sku: String, orderId: String, cause: Throwable) {
val reason = when (cause) {
is BazaarNotFoundException -> "not_installed"
is BazaarNotSupportedException, is SubsNotSupportedException, is IAPNotSupportedException,
is DynamicPriceNotSupportedException -> "old_bazaar"
else -> null
}
Log.e(TAG, "bazaar purchase failed: ${reason ?: cause.javaClass.simpleName}", cause)
if (reason != null) send(orderId, error = "unavailable", message = reason)
else recoverOwned(sku, orderId, cause)
}
/**
* اشتراکِ همین محصول که پولش رفته ولی تأییدِ سرور نرسیده بود جلوی خریدِ دوباره را می‌گیرد. فرانت در تلاشِ
* بعدی همان سفارش را می‌فرستد؛ اگر اشتراکِ همان سفارش (payload) پیدا شد، توکنش برمی‌گردد تا سرور ثبتش کند.
*/
private fun recoverOwned(sku: String, orderId: String, cause: Throwable) {
val p = payment ?: return send(orderId, error = "failed", message = cause.message)
p.getSubscribedProducts {
querySucceed { owned ->
val match = owned.firstOrNull { it.productId == sku && it.payload == orderId }
if (match != null) send(orderId, token = match.purchaseToken)
else send(orderId, error = "failed", message = cause.message)
}
queryFailed { send(orderId, error = "failed", message = cause.message) }
}
}
/**
* قیمتِ ثبت‌شده در پیشخانِ بازار برای هر SKU (متنِ آماده‌ی نمایش، مثلِ «۸۹۸,۰۰۰ تومان»)، تا فرانت روی کاشیِ
* بازار همان را نشان دهد. API سرور-به-سرورِ بازار قیمت نمی‌دهد؛ فقط Poolakey روی گوشی.
* نتیجه: رویدادِ «tadrisino:bazaar-prices» با { prices: { sku: price } } یا { error }.
*/
private fun prices(skus: List<String>) {
val p = payment ?: return sendPrices(error = "bazaar not connected")
p.getSubscriptionSkuDetails(skus) {
getSkuDetailsSucceed { list ->
val map = JSONObject()
list.forEach { map.put(it.sku, it.price) }
sendPrices(prices = map)
}
getSkuDetailsFailed { sendPrices(error = it.message ?: "failed") }
}
}
private fun sendPrices(prices: JSONObject? = null, error: String? = null) {
val detail = JSONObject()
if (prices != null) detail.put("prices", prices)
if (error != null) detail.put("error", error)
runOnUiThread {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar-prices', { detail: $detail }));", null,
)
}
}
private fun send(orderId: String, token: String? = null, error: String? = null, message: String? = null) {
val detail = JSONObject().put("orderId", orderId)
if (token != null) detail.put("purchaseToken", token)
if (error != null) detail.put("error", error)
if (message != null) detail.put("message", message)
runOnUiThread {
webView.evaluateJavascript(
"window.dispatchEvent(new CustomEvent('tadrisino:bazaar', { detail: $detail }));", null,
)
}
}
/** متدهای این کلاس روی threadِ WebView صدا زده می‌شوند، نه UI. */
private inner class Bridge {
/** قراردادش در src/lib/bazaar.tsِ فرانت است. */
@JavascriptInterface
fun bazaarBuy(sku: String, orderId: String, dynamicPriceToken: String) {
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
buy(sku, orderId, dynamicPriceToken)
}
}
/**
* لرزشِ کوتاهِ رابط (src/lib/sound.tsِ فرانت). `navigator.vibrate` در همه‌ی WebViewها اجرا نمی‌شود؛
* این مستقیم از Vibratorِ گوشی است. ورودی آرایه‌ی JSON از میلی‌ثانیه‌ها: [لرزش، مکث، لرزش، …].
*/
@JavascriptInterface
fun vibrate(patternJson: String) {
val pattern = try {
val arr = JSONArray(patternJson)
LongArray(arr.length()) { i -> arr.getLong(i).coerceIn(0, 1000) }
} catch (e: Exception) {
Log.w(TAG, "bad vibrate pattern", e)
return
}
if (pattern.isEmpty()) return
val vibrator = if (Build.VERSION.SDK_INT >= 31) {
(getSystemService(VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(VIBRATOR_SERVICE) as Vibrator
}
// [لرزش، مکث، …] → قالبِ اندروید که با مکث شروع می‌شود.
val timings = longArrayOf(0) + pattern
if (Build.VERSION.SDK_INT >= 26) vibrator.vibrate(VibrationEffect.createWaveform(timings, -1))
else @Suppress("DEPRECATION") vibrator.vibrate(timings, -1)
}
/** فرانت اولین صفحه‌ی واقعی را کشید؛ اسپلشِ سیستم می‌تواند برود. */
@JavascriptInterface
fun appReady() {
webReady = true
runOnUiThread { hideOffline() }
}
/** skusJson آرایه‌ی JSON از SKUهای اشتراک. */
@JavascriptInterface
fun bazaarPrices(skusJson: String) {
val skus = try {
val arr = JSONArray(skusJson)
List(arr.length()) { i -> arr.getString(i) }
} catch (e: Exception) {
Log.w(TAG, "bad skus", e)
return
}
runOnUiThread {
if (!isOurPage(Uri.parse(webView.url ?: ""))) return@runOnUiThread
prices(skus)
}
}
/** فرانت با این می‌فهمد درگاه داخلِ اپ باز می‌شود (isPaymentPage)، نه در مرورگرِ بیرونی. */
@JavascriptInterface
fun paysInApp(): Boolean = true
}
override fun onDestroy() {
connection?.disconnect()
super.onDestroy()
}
companion object {
private const val TAG = "Tadrisino"
private const val RETRY_MS = 5_000L
private const val READY_WATCHDOG_MS = 15_000L
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- همان تصویرِ آیکونِ اپ؛ حاشیه برای این‌که ماسکِ گردِ اندروید ۱۲ گوشه‌هایش را نبُرد. -->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/splash_icon_art"
android:insetLeft="22%"
android:insetTop="22%"
android:insetRight="22%"
android:insetBottom="22%" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">تدریسینو ریاضی</string>
</resources>

View File

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tadrisino" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">#ED6956</item>
<item name="android:windowBackground">@android:color/white</item>
</style>
<!--
اسپلشِ سیستم: آیکونِ اپ روی سفید، تا وقتی صفحه‌ی وب خودش بگوید آماده است (appReady) روی صفحه می‌ماند.
پیش از این، اسپلش می‌آمد، بعد WebView سفیدِ خالی تا رسیدنِ HTML، بعد اسپلشِ وب، بعد خانه.
سفید است چون windowBackgroundِ پایین هم سفید است و اسپلشِ وب (index.html) هم سفید است.
-->
<style name="Theme.Tadrisino.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@android:color/white</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="postSplashScreenTheme">@style/Theme.Tadrisino</item>
</style>
</resources>

View File

@@ -1,4 +0,0 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

View File

@@ -1,2 +0,0 @@
android.useAndroidX=true
org.gradle.jvmargs=-Xmx2g

View File

@@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// Poolakey (کتابخانه‌ی پرداختِ کافه‌بازار) فقط روی jitpack منتشر می‌شود.
maven { url = uri("https://jitpack.io") }
}
}
rootProject.name = "Tadrisino"
include(":app")

120
default_shadcn_theme.css Normal file
View File

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

View File

@@ -1,66 +0,0 @@
#!/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)"
# DEPLOY_BUNDLE=/مسیر/فایل.bundle: کد از یک git bundle، نه از gitea (مثلِ deploy.shِ بک‌اند): برای وقتی که سرور
# به gitea نمی‌رسد یا اعتبارنامه ندارد. فقط وقتی صریحاً داده شود؛ بی‌آن نرسیدن به gitea دیپلو را می‌ایستاند.
if [ -n "${DEPLOY_BUNDLE:-}" ]; then
[ -f "$DEPLOY_BUNDLE" ] || fail "DEPLOY_BUNDLE=$DEPLOY_BUNDLE وجود ندارد."
git bundle verify "$DEPLOY_BUNDLE" >/dev/null 2>&1 || fail "bundle معتبر نیست یا کامیتِ پایه‌اش این‌جا نیست."
git fetch "$DEPLOY_BUNDLE" "$BRANCH:refs/remotes/origin/$BRANCH" || fail "گرفتن از bundle نشد."
else
git fetch origin "$BRANCH" || fail "به gitea وصل نشد (اگر فقط از این سرور: DEPLOY_BUNDLE، بالای همین خط)."
fi
printf ' origin/%s = %s\n' "$BRANCH" "$(git rev-parse --short "origin/$BRANCH")"
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
# فایل‌های buildهای قبلی (نامشان hash دارد و با هم برخورد نمی‌کنند) تا ۳۰ روز می‌مانند: گوشی‌ای که اپ از قبل
# در آن باز است هنوز index.htmlِ قبلی را دارد و با باز کردنِ صفحه‌ای که بار نشده همان نام‌ها را می‌خواهد؛ بی‌این
# ۴۰۴ و صفحه‌ی سفید می‌گرفت. cp -p زمانِ فایل را نگه می‌دارد، پس هر فایل ۳۰ روز بعد از buildِ خودش کنار می‌رود.
if [ -d dist/assets ]; then
find dist/assets -type f -mtime -30 -exec cp -p --update=none {} dist.next/assets/ \;
fi
step "جابه‌جایی"
rm -rf dist.prev
[ -d dist ] && mv dist dist.prev
mv dist.next dist
API="$(grep -oh 'https\?://[a-zA-Z0-9.-]*/api' dist/assets/*.js | head -1)"
printf ' منتشر شد. آدرسِ API در build: %s\n' "${API:-(هم‌دامنه /api یا config.js)}"
printf ' کشِ آروان را برای index.html و config.js پاک کنید اگر تغییری دیده نشد.\n'

61
guidelines/Guidelines.md Normal file
View File

@@ -0,0 +1,61 @@
**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,42 +1,22 @@
<!doctype html>
<html lang="fa" dir="rtl"> <!DOCTYPE html>
<html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ED6956" /> <title>Update Educational App Mascot</title>
<meta name="description" content="تدریسینو ریاضی — یادگیری ریاضی پایه سوم تا دوازدهم، قدم‌به‌قدم و با دستیار هوشمند." /> <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" /> <meta name="robots" content="noindex, nofollow" />
<title>تدریسینو ریاضی</title> <style>html, body { height: 100%; margin: 0; } #root { height: 100%; }</style>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
<script src="/config.js"></script>
<!-- اسپلشِ ثابت: همین‌جا در HTML است تا از اولین فریم دیده شود (نه صفحه‌ی سفید تا بار شدنِ JS)؛
SplashScreen در React عینِ همین را می‌کشد، پس هنگامِ تحویل چیزی نمی‌پرد. فونتِ سیستم، چون
Vazirmatn بعد از CSS می‌رسد و عوض‌شدنِ فونت خودش یک پرش است. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preload" as="image" href="/app-icon.png" />
<script>
(function () {
var b = window.TadrisinoAndroid;
if (b) document.documentElement.className = typeof b.appReady === 'function' ? 'in-app' : 'in-old-app';
})();
</script>
<style>
.boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px; background: #fff; }
.boot-splash img { width: 144px; height: 144px; display: block; }
/* داخل اپ اندروید اسپلش بومی روی صفحه است و سایت زیرش بار می‌شود؛ اسپلش وب نباید یک بار دیگر
«عوض شدن» نشان دهد. APK تازه (appReady دارد): اسپلش وب نامرئی است. APK قدیمی: فقط آیکون،
بی‌نام، تا کمتر از اسپلش سیستم فرق کند. */
.in-app .boot-splash > * { visibility: hidden; }
.in-old-app .boot-splash span { display: none; }
.in-old-app .boot-splash img { width: 160px; height: 160px; }
.boot-splash span { font: 900 min(34px, 8.5vw)/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; white-space: nowrap; }
</style>
</head> </head>
<body> <body>
<div id="root">
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو ریاضی</span></div> <div id="root"></div>
</div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

3342
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,90 @@
{ {
"name": "tadrisino-math-app", "name": "@figma/my-make-file",
"private": true, "private": true,
"version": "2.0.0", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "build": "vite build",
"build": "tsc --noEmit && vite build", "dev": "vite"
"typecheck": "tsc --noEmit",
"preview": "vite preview"
}, },
"dependencies": { "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", "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", "lucide-react": "0.487.0",
"motion": "12.23.24", "motion": "12.23.24",
"react": "18.3.1", "next-themes": "0.4.6",
"react-dom": "18.3.1", "react-day-picker": "8.10.1",
"vazirmatn": "33.0.3" "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"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "4.1.12", "@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", "@vitejs/plugin-react": "4.7.0",
"tailwindcss": "4.1.12", "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" "vite": "6.3.5"
} }
}
} }

View File

@@ -1,28 +0,0 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<!-- Referer باید از دامنه‌ی خودمان برود؛ زرین‌پال بی‌آن صفحه را باز نمی‌کند (src/pay.ts). -->
<meta name="referrer" content="strict-origin-when-cross-origin" />
<title>رفتن به درگاه — تدریسینو ریاضی</title>
<script src="/config.js"></script>
<style>
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f6f5fb; color: #1a1a2e; font-family: Vazirmatn, system-ui, sans-serif; }
main { text-align: center; padding: 24px; max-width: 340px; }
.spin { width: 36px; height: 36px; margin: 0 auto 16px; border-radius: 50%; border: 4px solid #f3c9c2; border-top-color: #ED6956; animation: s 0.8s linear infinite; }
@keyframes s { to { transform: rotate(360deg); } }
a { display: none; margin-top: 16px; padding: 14px 24px; border-radius: 16px; background: #ED6956; color: #fff; font-weight: 800; text-decoration: none; }
p { line-height: 1.9; }
</style>
</head>
<body>
<main>
<div class="spin" id="spin"></div>
<p id="msg">داریم می‌ریم به درگاه پرداخت…</p>
<a id="go" href="#">رفتن به درگاه</a>
</main>
<script type="module" src="/src/pay.ts"></script>
</body>
</html>

10
pnpm-workspace.yaml Normal file
View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,13 +0,0 @@
// تنظیماتِ زمانِ اجرای اپ — بعد از build هم روی سرور قابلِ ویرایش است.
//
// apiBaseUrl خالی یعنی همان آدرسی که موقعِ build در .env.production بوده.
// برای جابه‌جا کردنِ بک‌اند بدونِ build دوباره، آدرسِ کامل را اینجا بنویسید:
// apiBaseUrl: "https://api.new-domain.ir/api",
// اگر CDN (آروان) جلوی سایت است، بعد از ویرایش کشِ این فایل را پاک کنید.
//
// paymentMethods: روش‌های پرداختی که برگه‌ی پرداخت نشان می‌دهد، به همین ترتیب؛ «zarinpal» و «bazaar».
// web = سایت در مرورگر، app = داخلِ اپِ اندروید. نسخه‌ی کافه‌بازار: app: ["bazaar"].
// نبودنِ این کلید یعنی پیش‌فرضِ src/lib/paymentMethods.ts.
window.__TADRISINO_CONFIG__ = {
apiBaseUrl: "",
};

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 244 B

View File

@@ -0,0 +1,87 @@
/**
* بررسیِ اتصال به بک‌اند — ابزارِ توسعه، نه بخشی از محصول.
*
* برای امتحان کردن، یک خط در App.tsx (یا هر جای دیگر) کافی‌ست:
*
* import { ConnectionCheck } from '@/api/ConnectionCheck';
* <ConnectionCheck />
*
* هیچ فایلِ دیگری را عوض نمی‌کند و اگر بک‌اند بالا نباشد هم اپ را نمی‌خواباند.
*/
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { api, ApiError } from '@/api';
import type { Grade } from '@/api';
type State =
| { kind: 'loading' }
| { kind: 'ok'; grades: Grade[] }
| { kind: 'error'; message: string; hint: string };
function hintFor(error: unknown): string {
if (!(error instanceof ApiError)) return 'خطای پیش‌بینی‌نشده — کنسول را ببینید.';
if (error.isNetwork) {
return 'سرور جواب نداد. VITE_API_BASE_URL درست است؟ بک‌اند بالاست؟';
}
if (error.status === 502) {
return 'nginx بالاست ولی جنگو نه — سرویسِ بک‌اند روی سرور اجرا نشده.';
}
if (error.status === 0) return 'ارتباط برقرار نشد.';
if (error.isRateLimited) return 'سقفِ نرخ خورد؛ کلاینت خودش دوباره تلاش می‌کند.';
if (error.isUnauthorized) return 'نیاز به ورود دارد (این اندپوینت نباید داشته باشد).';
return `سرور ${error.status} داد.`;
}
export function ConnectionCheck() {
const [state, setState] = useState<State>({ kind: 'loading' });
useEffect(() => {
const controller = new AbortController();
api
.grades({ signal: controller.signal })
.then((grades) => setState({ kind: 'ok', grades }))
.catch((error: unknown) => {
if (controller.signal.aborted) return;
setState({
kind: 'error',
message: error instanceof Error ? error.message : String(error),
hint: hintFor(error),
});
});
return () => controller.abort();
}, []);
const box: CSSProperties = {
position: 'fixed', bottom: 12, right: 12, zIndex: 9999,
maxWidth: 320, padding: '10px 12px', borderRadius: 10,
font: '12px/1.6 system-ui, sans-serif', direction: 'rtl',
background: '#fff', border: '1px solid #e5e7eb',
boxShadow: '0 4px 16px rgba(0,0,0,.08)',
};
if (state.kind === 'loading') {
return <div style={box}>در حالِ بررسیِ اتصال به بک‌اند…</div>;
}
if (state.kind === 'error') {
return (
<div style={{ ...box, borderColor: '#fecaca' }}>
<b style={{ color: '#b91c1c' }}>اتصال برقرار نشد</b>
<div style={{ marginTop: 4 }}>{state.message}</div>
<div style={{ marginTop: 4, color: '#6b7280' }}>{state.hint}</div>
<div style={{ marginTop: 6, color: '#9ca3af' }}>{api.http.baseUrl}</div>
</div>
);
}
return (
<div style={{ ...box, borderColor: '#bbf7d0' }}>
<b style={{ color: '#15803d' }}>اتصال برقرار است</b>
<div style={{ marginTop: 4 }}>
{state.grades.length} پایه از سرور آمد
{state.grades.length > 0 && `: ${state.grades.map((g) => g.title).join('، ')}`}
</div>
<div style={{ marginTop: 6, color: '#9ca3af' }}>{api.http.baseUrl}</div>
</div>
);
}

106
src/api/README.md Normal file
View File

@@ -0,0 +1,106 @@
# اتصال به بک‌اند
کلِ ارتباط با سرور از همین پوشه رد می‌شود. **هیچ‌جای اپ مستقیم `fetch` نزنید** —
مدیریتِ توکن، تکرارِ درخواست و رفتارِ درستِ خطا اینجا یک‌جا حل شده‌اند.
## راه‌اندازی
**هیچ تنظیماتی لازم نیست.** به‌طورِ پیش‌فرض به بک‌اندِ مشترک
(`https://api.math.tadrisino2.ir/api`) وصل می‌شود:
```bash
npm install
npm run dev
```
فقط اگر بک‌اند را روی ماشینِ خودتان بالا آورده‌اید:
```bash
cp .env.example .env # و خطِ VITE_API_BASE_URL را باز کنید
```
برای اینکه ببینید اتصال برقرار است، یک خط کافی‌ست (بعداً پاکش کنید):
```tsx
import { ConnectionCheck } from '@/api/ConnectionCheck';
// داخلِ خروجیِ App:
<ConnectionCheck />
```
گوشه‌ی صفحه سبز می‌شود و فهرستِ پایه‌ها را از سرور نشان می‌دهد، یا قرمز
می‌شود و می‌گوید دقیقاً کجای کار می‌لنگد.
## استفاده
```tsx
import { api, ApiError } from '@/api';
const grades = await api.grades(); // lessonCount=0 یعنی پایه خالی است
const home = await api.home(); // بدونِ آرگومان: پایه‌ی خودِ کاربر
const steps = await api.nodes.lessons(nodeId); // کارت‌های نقشه‌ی مهارت
const intro = await api.lessons.intro(lessonId); // توضیح + مثال + گام‌به‌گام
const answer = await api.ai.ask('این رو نفهمیدم', { lessonId });
```
### چند نکته که وقت می‌گیرند اگر ندانید
- `grades()` حالا `lessonCount` می‌دهد. صفر یعنی آن پایه هنوز محتوایی ندارد —
گزینه‌اش را غیرفعال کنید، وگرنه کاربر انتخابش می‌کند و صفحه‌ی خالی می‌بیند.
- `home()` در هر مبحث `icon` و `color` و در هر فصل `blurb` می‌دهد؛ لازم نیست
رنگ‌ها را هاردکد کنید.
- `home()` فقط شناسه‌ی درس‌ها را می‌دهد. برای کارت‌های نقشه‌ی مهارت (عنوان،
آزمون یا ویدیو، «۵ سوال»/«۱۲ دقیقه»، انجام‌شده) از `nodes.lessons(nodeId)`
استفاده کنید — یک درخواست برای کلِ مبحث، نه یکی به‌ازای هر کارت.
- `lessons.intro()` متن را سه‌تکه می‌دهد: `introText` (توضیح)، `example`، و
`steps` (حلِ گام‌به‌گام). `kind` می‌گوید آزمون است یا ویدیو.
- `profile.update({ age })` هم کار می‌کند؛ سرور خودش به سالِ تولدِ شمسی تبدیل
می‌کند. `age` و `birthYear` را با هم نفرستید.
### ورود
```tsx
await api.auth.guest(deviceId); // «شروع سریع»، بدونِ شماره
// یا
const { devOtp } = await api.auth.requestOtp('09120000000');
await api.auth.login('09120000000', otp); // نامِ فیلد در سرور otp است
```
توکن خودش در `localStorage` ذخیره می‌شود و به هر درخواست اضافه می‌گردد.
### خطاها
```tsx
try {
await api.ai.ask(text, { lessonId });
} catch (error) {
if (error instanceof ApiError) {
if (error.isRateLimited) { /* ۴۲۹ — خیلی سریع پرسیده */ }
if (error.isProviderDown) { /* ۵۰۳ — سرویسِ AI موقتاً قطع */ }
if (error.isNetwork) { /* اصلاً نرسید یا timeout */ }
}
}
```
## چرا این‌قدر ملاحظه در `client.ts`
هر تکه‌اش برای حالتی‌ست که فقط زیرِ فشار پیش می‌آید:
| مسئله | اگر نباشد چه می‌شود |
|---|---|
| `timeout` روی هر درخواست | یک بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد؛ مرورگر به سقفِ ۶ درخواستِ همزمان می‌خورد و کلِ اپ قفل می‌شود |
| backoff با **jitter** | هزار کلاینت بعد از یک خطا *همزمان* دوباره می‌زنند و موجِ دوم سرور را می‌خواباند |
| تکرارِ خودکار فقط روی `GET` | تکرارِ `POST` یعنی سفارش یا پاسخِ تکراری |
| `Idempotency-Key` روی ثبتِ پاسخ و سفارش | دابل‌کلیکِ کاربر دو بار XP یا دو سفارش می‌سازد |
| `refresh` تک‌نسخه‌ای | ۲۰ درخواستِ همزمانِ ۴۰۱ ⇒ ۲۰ بار refresh ⇒ سرور همه را throttle می‌کند |
| ادغامِ `GET`های یکسان | دو کامپوننت که با هم یک چیز می‌خواهند، دو برابر بار می‌سازند |
| احترام به هدرِ `Retry-After` | بک‌اند صریح می‌گوید چقدر صبر کن؛ نادیده گرفتنش throttle را طولانی‌تر می‌کند |
`credentials: 'omit'` هم عمدی‌ست: احرازِ هویت توکنِ Bearer در هدر است و سمتِ
سرور `CORS_ALLOW_CREDENTIALS=False` تنظیم شده. عوضش نکنید.
## چیزی که هنوز نیست
- صفحه‌ی ورود در اپ وجود ندارد؛ `api.auth` آماده است ولی UI ندارد.
- `api.home()` ساختارِ واقعیِ درس‌ها را می‌دهد، ولی وصل کردنش به صفحه‌ها
هنوز انجام نشده — این PR عمداً `App.tsx` را دست نزده تا با کارِ در جریان
تصادف نکند.

317
src/api/client.ts Normal file
View File

@@ -0,0 +1,317 @@
/**
* هسته‌ی ارتباط با بک‌اندِ تدریسینو.
*
* این فایل عمداً هیچ پکیجی اضافه نمی‌کند — فقط fetch استاندارد. هر چیزی که
* اینجاست برای یک حالتِ خراب است که در فشار پیش می‌آید، نه برای حالتِ خوب:
*
* • timeout — بدونِ آن، یک بک‌اندِ کند سوکت‌ها را باز نگه می‌دارد تا مرورگر
* به سقفِ ۶ درخواستِ همزمان بخورد و کلِ اپ قفل شود.
* • backoff با jitter — وقتی سرور ۴۲۹/۵۰۳ می‌دهد، تکرارِ فوری فقط بار را
* بیشتر می‌کند. jitter لازم است وگرنه هزار کلاینت *همزمان* دوباره می‌زنند
* و موجِ بعدی از موجِ اول بدتر است.
* • فقط GET خودکار تکرار می‌شود — تکرارِ POST یعنی سفارشِ تکراری.
* • refresh تک‌نسخه‌ای — اگر ۲۰ درخواست با هم ۴۰۱ بگیرند، بدونِ این ۲۰ بار
* refresh صدا زده می‌شود و سرور همه را throttle می‌کند.
* • حذفِ درخواستِ تکراری — دو کامپوننت که همزمان یک GET را می‌خواهند، یک
* درخواست می‌فرستند نه دو تا.
*/
export interface Tokens {
token: string;
refreshToken: string;
}
export class ApiError extends Error {
constructor(
readonly status: number,
message: string,
readonly payload: Record<string, unknown> = {},
) {
super(message);
this.name = 'ApiError';
}
/** ۴۰۱ — توکن نامعتبر یا منقضی. کاربر باید دوباره وارد شود. */
get isUnauthorized() { return this.status === 401; }
/** ۴۲۹ — سقفِ نرخ. `retryAfterMs` می‌گوید چقدر صبر کنید. */
get isRateLimited() { return this.status === 429; }
/** ۵۰۳ — سرویسِ بیرونی (هوش مصنوعی/پیامک) موقتاً در دسترس نیست. */
get isProviderDown() { return this.status === 503; }
/** درخواست اصلاً به سرور نرسید یا timeout شد. */
get isNetwork() { return this.status === 0; }
retryAfterMs?: number;
}
/* ─── تنظیمات ───────────────────────────────────────────────────────────── */
export interface ClientOptions {
/** پیش‌فرض از VITE_API_BASE_URL خوانده می‌شود. */
baseUrl?: string;
/** سقفِ زمانِ هر درخواست. پیش‌فرض ۱۵ ثانیه. */
timeoutMs?: number;
/** بیشترین تعدادِ تلاشِ دوباره برای GET. پیش‌فرض ۳. */
maxRetries?: number;
storage?: TokenStorage;
/** وقتی نشست به‌کل باطل شد (refresh هم شکست خورد) صدا زده می‌شود. */
onSignedOut?: () => void;
}
export interface TokenStorage {
get(): Tokens | null;
set(t: Tokens | null): void;
}
const STORAGE_KEY = 'tadrisino.tokens';
/** localStorage در حالتِ خصوصیِ مرورگر throw می‌کند؛ هیچ‌کدام نباید اپ را بخواباند. */
export function localTokenStorage(): TokenStorage {
return {
get() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
}
},
set(t) {
try {
if (t) localStorage.setItem(STORAGE_KEY, JSON.stringify(t));
else localStorage.removeItem(STORAGE_KEY);
} catch {
/* نادیده */
}
},
};
}
/**
* بک‌اندِ مشترک. پیش‌فرض است تا کسی که تازه ریپو را clone می‌کند بدونِ هیچ
* تنظیماتی بتواند اپ را اجرا کند و داده‌ی واقعی ببیند.
* برای بک‌اندِ محلی، در فایلِ .env مقدارِ VITE_API_BASE_URL را عوض کنید.
*/
const DEFAULT_API_BASE_URL = 'https://api.math.tadrisino2.ir/api';
function resolveBaseUrl(explicit?: string): string {
if (explicit) return explicit.replace(/\/+$/, '');
const fromEnv = (import.meta as { env?: Record<string, string> }).env?.VITE_API_BASE_URL;
if (fromEnv) return fromEnv.replace(/\/+$/, '');
return DEFAULT_API_BASE_URL;
}
/* ─── کمکی‌ها ───────────────────────────────────────────────────────────── */
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
/** full jitter — هزار کلاینت نباید همزمان بیدار شوند. */
function backoffMs(attempt: number, retryAfterMs?: number): number {
if (retryAfterMs != null) return Math.min(retryAfterMs, 30_000);
const ceiling = Math.min(1000 * 2 ** attempt, 8000);
return Math.random() * ceiling;
}
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>((r) => setTimeout(r, ms));
/** برای POSTهایی که چیزی می‌سازند؛ سرور هدرِ Idempotency-Key را می‌پذیرد. */
function newIdempotencyKey(): string {
const c = globalThis.crypto;
if (c && 'randomUUID' in c) return c.randomUUID();
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
export interface RequestOptions {
/** هدرهای اضافه. */
headers?: Record<string, string>;
/** برای درخواست‌های کند مثل پرسش از دستیار. */
timeoutMs?: number;
/** لغو از بیرون (مثلاً کاربر از صفحه رفت). */
signal?: AbortSignal;
/** POST را هم قابلِ تکرار می‌کند — فقط وقتی Idempotency-Key می‌فرستید. */
retry?: boolean;
/** کلیدِ یکتا تا تکرارِ درخواست سفارشِ تکراری نسازد. */
idempotencyKey?: string;
}
/* ─── کلاینت ────────────────────────────────────────────────────────────── */
export function createHttpClient(options: ClientOptions = {}) {
const baseUrl = resolveBaseUrl(options.baseUrl);
const defaultTimeout = options.timeoutMs ?? 15_000;
const maxRetries = options.maxRetries ?? 3;
const storage = options.storage ?? localTokenStorage();
/** درخواست‌های GETِ در جریان، تا دو کامپوننت یک چیز را دوبار نگیرند. */
const inFlight = new Map<string, Promise<unknown>>();
/** فقط یک refresh در هر لحظه. */
let refreshing: Promise<Tokens | null> | null = null;
async function doRefresh(): 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: AbortSignal.timeout(defaultTimeout),
});
if (!response.ok) return null;
const data = (await response.json()) as Tokens;
storage.set({ token: data.token, refreshToken: data.refreshToken });
return data;
} catch {
return null;
} finally {
// در میکروتسکِ بعدی آزاد می‌شود تا هم‌زمان‌ها همین نتیجه را ببینند.
queueMicrotask(() => { refreshing = null; });
}
})();
return refreshing;
}
async function once<T>(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
allowRefresh: boolean,
): Promise<T> {
const tokens = storage.get();
const headers: Record<string, string> = { Accept: 'application/json', ...opts.headers };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (tokens?.token) headers.Authorization = `Bearer ${tokens.token}`;
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
// timeoutِ خودمان + لغوِ بیرونی، هر کدام زودتر رسید.
const timer = AbortSignal.timeout(opts.timeoutMs ?? defaultTimeout);
const signal = opts.signal ? anySignal([timer, opts.signal]) : timer;
let response: Response;
try {
response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
// احرازِ هویت هدرِ Bearer است، نه کوکی — سمتِ سرور هم عمداً
// CORS_ALLOW_CREDENTIALS=False است. اینجا هم نباید عوض شود.
credentials: 'omit',
});
} catch (err) {
const aborted = err instanceof DOMException && err.name === 'AbortError';
throw new ApiError(0, aborted ? 'زمانِ درخواست تمام شد' : 'ارتباط با سرور برقرار نشد');
}
if (response.status === 401 && allowRefresh) {
const refreshed = await doRefresh();
if (refreshed) return once<T>(method, path, body, opts, false);
storage.set(null);
options.onSignedOut?.();
}
const text = await response.text();
let parsed: Record<string, unknown> = {};
if (text) {
try {
parsed = JSON.parse(text) as Record<string, unknown>;
} catch {
// سرور HTML داد (۵۰۲ از nginx مثلاً) — پیامِ خام گمراه‌کننده است.
parsed = {};
}
}
if (!response.ok) {
const detail =
(parsed.detail as string) ??
(parsed.error as string) ??
(response.status === 502 ? 'سرور در دسترس نیست' : `خطای ${response.status}`);
const error = new ApiError(response.status, detail, parsed);
error.retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
throw error;
}
return parsed as T;
}
async function request<T>(
method: string,
path: string,
body?: unknown,
opts: RequestOptions = {},
): Promise<T> {
const retriable = opts.retry ?? method === 'GET';
let lastError: ApiError | undefined;
for (let attempt = 0; attempt <= (retriable ? maxRetries : 0); attempt++) {
try {
return await once<T>(method, path, body, opts, true);
} catch (err) {
if (!(err instanceof ApiError)) throw err;
lastError = err;
const worthRetrying = err.isNetwork || RETRIABLE_STATUS.has(err.status);
if (!retriable || !worthRetrying || attempt === maxRetries) throw err;
if (opts.signal?.aborted) throw err;
await sleep(backoffMs(attempt, err.retryAfterMs));
}
}
throw lastError ?? new ApiError(0, 'خطای نامشخص');
}
/** GETها با هم ادغام می‌شوند تا رندرِ همزمانِ چند کامپوننت یک درخواست بدهد. */
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;
}
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(),
}),
};
}
export type HttpClient = ReturnType<typeof createHttpClient>;
/** AbortSignal.any هنوز همه‌جا نیست؛ این جایگزینِ کوچکش است. */
function anySignal(signals: AbortSignal[]): AbortSignal {
const anyFn = (AbortSignal as { any?: (s: AbortSignal[]) => AbortSignal }).any;
if (anyFn) return anyFn(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;
}

View File

@@ -1,374 +0,0 @@
/**
* هسته‌ی ارتباط با بک‌اند. فقط 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> = {},
/** فقط برای status 0: درخواست به‌خاطرِ timeout قطع شد (اینترنت کند)، نه رسیدن‌نداشتن (اینترنت قطع). */
readonly timedOut = false,
) {
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;
}
export const TOKENS_STORAGE_KEY = 'tadrisino.tokens';
/** localStorage در حالتِ خصوصیِ بعضی مرورگرها throw می‌کند؛ اپ نباید بخوابد. */
export const localTokenStorage: TokenStorage = {
get() {
try {
const raw = localStorage.getItem(TOKENS_STORAGE_KEY);
return raw ? (JSON.parse(raw) as Tokens) : null;
} catch {
return null;
}
},
set(tokens) {
try {
if (tokens) localStorage.setItem(TOKENS_STORAGE_KEY, JSON.stringify(tokens));
else localStorage.removeItem(TOKENS_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;
readTimeoutMs?: number;
maxRetries?: number;
}
const RETRIABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
function backoffMs(attempt: number, retryAfterMs?: number): number {
// Retry-After کمینه است، نه زمانِ دقیق: وقتی سرور زیرِ بار هزاران گوشی را هم‌زمان رد می‌کند، اگر همه
// دقیقاً بعد از همان چند ثانیه برگردند همان اوج دوباره ساخته می‌شود. پس بینِ ۱ تا ۲ برابرِ آن پخش می‌شوند.
if (retryAfterMs != null) return Math.min(retryAfterMs * (1 + Math.random()), 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;
// خواندن‌ها (GET) تکرارپذیرند و کاربر پشتِ اسپلش منتظر است: ۸ ثانیه کافی است، بعدش تلاشِ دوباره یا پیامِ «اینترنت کند است».
// نوشتن‌ها (POST/PATCH) همان ۱۵ می‌مانند: قطعِ زودهنگام یعنی کاربر دوباره می‌زند و ممکن است ثبتِ دوباره شود.
const readTimeout = options.readTimeoutMs ?? 8_000;
const maxRetries = options.maxRetries ?? 2;
const inFlight = new Map<string, Promise<unknown>>();
const signedOutListeners = new Set<() => void>();
let refreshing: Promise<Tokens | 'invalid' | ApiError> | 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());
}
/**
* نتیجه‌ی تمدید سه حالت دارد و فقط یکی‌اش یعنی خروج:
* • Tokens — تمدید شد.
* • 'invalid' — سرور گفت refresh token معتبر نیست (۴۰۱)؛ نشست واقعاً تمام است.
* • ApiError — سرور در دسترس نبود یا سقف را زد (۴۲۹، ۵xx، قطعیِ شبکه)؛ نشست سالم است.
* تا ۲۰۲۶-۱۰-۰۳ هر شکستی خروج حساب می‌شد: پشتِ وای‌فایِ مدرسه که سقفِ تمدیدِ IP پر می‌شد،
* دانش‌آموزِ شماره‌دار به صفحه‌ی ورود پرت می‌شد و باید دوباره پیامک می‌گرفت.
*/
async function refreshTokens(): Promise<Tokens | 'invalid' | ApiError> {
const current = storage.get();
if (!current?.refreshToken) return 'invalid';
if (refreshing) return refreshing;
refreshing = (async (): Promise<Tokens | 'invalid' | ApiError> => {
try {
let response: Response;
try {
response = await fetch(`${baseUrl}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: current.refreshToken }),
signal: timeoutSignal(defaultTimeout),
credentials: 'omit',
});
} catch {
return new ApiError(0, 'ارتباط با سرور برقرار نشد');
}
if (response.status === 401) {
// تبِ دیگری همین توکن را زودتر تمدید کرده (سرور توکنِ قبلی را باطل می‌کند) و توکنِ تازه
// را در همان localStorageِ مشترک گذاشته؛ آن نشست سالم است و نباید هر دو تب خارج شوند.
const latest = storage.get();
if (latest?.refreshToken && latest.refreshToken !== current.refreshToken) return latest;
return 'invalid';
}
if (!response.ok) return errorFrom(response, await parseBody(response));
const data = (await response.json()) as Tokens;
const next = { token: data.token, refreshToken: data.refreshToken || current.refreshToken };
// کاربر در همین فاصله خارج شد (یا حسابش را حذف کرد): توکنِ تازه نباید نشستِ پاک‌شده را روی گوشی زنده کند.
if (!storage.get()) return 'invalid';
storage.set(next);
return next;
} finally {
queueMicrotask(() => {
refreshing = null;
});
}
})();
return refreshing;
}
async function send(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
): Promise<Response> {
// بدنه‌ی تابعی سرِ هر ارسال از نو ساخته می‌شود (خروج: refreshTokenِ همان لحظه، نه نسخه‌ی پیش از refresh).
const payload = typeof body === 'function' ? (body as () => unknown)() : body;
const headers: Record<string, string> = { Accept: 'application/json', ...authHeaders() };
if (payload !== undefined) headers['Content-Type'] = 'application/json';
if (opts.idempotencyKey) headers['Idempotency-Key'] = opts.idempotencyKey;
const timer = timeoutSignal(opts.timeoutMs ?? (method === 'GET' ? readTimeout : defaultTimeout));
try {
return await fetch(`${baseUrl}${path}`, {
method,
headers,
body: payload === undefined ? undefined : JSON.stringify(payload),
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 ? 'زمان درخواست تمام شد' : 'ارتباط با سرور برقرار نشد', {}, timedOut);
}
}
/** یک درخواست، با یک‌بار refresh در صورتِ ۴۰۱. */
async function sendWithRefresh(
method: string,
path: string,
body: unknown,
opts: RequestOptions,
): Promise<Response> {
const sentToken = storage.get()?.token;
let response = await send(method, path, body, opts);
if (response.status === 401 && sentToken) {
// درخواستی که با توکنِ کهنه رفته و ۴۰۱اش بعد از تمامِ refreshِ درخواستِ دیگری رسیده، نباید دوباره
// refresh کند (refreshing آن موقع دیگر null است): توکنِ تازه در storage هست، فقط با آن دوباره بفرست.
const latest = storage.get()?.token;
if (latest && latest !== sentToken) return send(method, path, body, opts);
const refreshed = await refreshTokens();
if (refreshed === 'invalid') signOut();
else if (refreshed instanceof ApiError) throw refreshed; // نشست سالم است؛ تکرارپذیر مثلِ خطای شبکه
else response = await send(method, path, body, opts);
}
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>;

View File

@@ -1,253 +1,162 @@
/** /**
* تنها درِ ارتباط با بک‌اند. هیچ جای دیگرِ اپ fetch نمی‌زند. * API تدریسینو — تنها جایی که فرانت باید برای صدا زدنِ بک‌اند به آن نگاه کند.
* *
* هر تابع یک اندپوینت است و نامِ فیلدها عیناً همان نامِ سرور است. * import { api } from '@/api';
* const home = await api.home();
*
* هیچ‌جای اپ نباید مستقیم fetch بزند: مسیرِ خام، مدیریتِ توکن و رفتارِ
* درستِ خطا همه اینجا یک‌جا جمع‌اند.
*
* هر مسیر و هر نامِ فیلد از OpenAPI واقعیِ سرویس گرفته شده. جاهایی که
* غافلگیرکننده‌اند کامنت خورده‌اند، چون اشتباهشان خطای ۴۰۰/۴۰۴ می‌دهد که
* از پیامش علت معلوم نیست.
*/ */
import { resolveApiBaseUrl } from '@/config'; import { ApiError, createHttpClient, localTokenStorage } from './client';
import { homeSnapshot } from '@/state/homeSnapshot'; import type { ClientOptions, HttpClient, RequestOptions, Tokens } from './client';
import { ApiError, createHttpClient, localTokenStorage, newIdempotencyKey, type TokenStorage } from './http'; import type {
import type * as T from './types'; AnswerResponse, AskResponse, Grade, HomeResponse, LessonIntro, NodeLessons, Plan,
Question, ReportReason, ReportResponse, UserProfile,
} from './types';
export { ApiError }; export { ApiError, localTokenStorage, createHttpClient };
export type * from './types'; export type { Tokens, HttpClient, ClientOptions, RequestOptions };
export * from './types';
/** /** پرسش از دستیار کند است (تولیدِ متن)؛ ۱۵ ثانیه‌ی پیش‌فرض کم است. */
* پاک شدنِ توکن (خروج، حذفِ حساب، نشستِ باطل در http.signOut) عکسِ خانه را هم پاک می‌کند؛ هر سه از همین const ASK_TIMEOUT_MS = 45_000;
* `set(null)` می‌گذرند، پس راهی برای ماندنِ خانه‌ی کاربرِ قبلی روی گوشیِ مشترک نمی‌ماند.
*/
const tokenStorage: TokenStorage = {
get: () => localTokenStorage.get(),
set(tokens) {
if (!tokens) homeSnapshot.clear();
localTokenStorage.set(tokens);
},
};
export const http = createHttpClient({ baseUrl: resolveApiBaseUrl(), storage: tokenStorage }); export function createApi(options: ClientOptions = {}) {
const http = createHttpClient(options);
const q = (params: Record<string, string | number | undefined>) => { return {
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== ''); http,
return entries.length ? `?${new URLSearchParams(entries.map(([k, v]) => [k, String(v)]))}` : '';
};
function saveTokens(data: T.AuthResponse) { /* ── ورود ─────────────────────────────────────────────────────────── */
// ورودِ تازه ممکن است کاربرِ دیگری باشد؛ عکسِ خانه فقط از پاسخِ سرورِ همین نشست ساخته می‌شود.
homeSnapshot.clear();
http.storage.set({ token: data.token, refreshToken: data.refreshToken ?? '' });
return data;
}
/** یک بخشِ مسیر؛ شناسه‌ای که از آدرسِ صفحه یا سرور می‌آید نباید با «/» یا «..» مسیرِ دیگری از API را صدا بزند. */
const seg = (value: string | number) => encodeURIComponent(String(value));
export const api = {
auth: { auth: {
isSignedIn: () => Boolean(http.storage.get()?.token), /** «شروع سریع» بدونِ شماره. توکنِ مهمان refreshToken ندارد. */
async guest(deviceId: string) {
/** const data = await http.post<{
* گامِ ۱: پیامکِ کد. `devOtp` فقط وقتی سرور در حالتِ توسعه است برمی‌گردد. userId: string; token: string; isNewUser: boolean; isGuest: boolean;
* مهلتِ ۴۵ ثانیه، نه ۱۵ِ پیش‌فرض: بک‌اند تا ۱۰+۳۰ ثانیه منتظرِ کاوه‌نگار می‌ماند }>('/auth/session', { type: 'guest', deviceId });
* (۲۰۲۶-۰۹-۲۷، پیامک رسید ولی جوابِ کاوه‌نگار دیر آمد). اگر این زودتر قطع کند، http.storage.set({ token: data.token, refreshToken: '' });
* کاربر «ارتباط برقرار نشد» می‌بیند در حالی که کد در گوشی‌اش است. return data;
*/ },
requestOtp: (phone: string) => /** گامِ ۱ — در حالتِ توسعه `devOtp` هم برمی‌گردد. */
http.post<{ success: boolean; devOtp?: string }>('/auth/otp/request', { phone }, { timeoutMs: 45_000 }), requestOtp(phone: string) {
return http.post<{ success: boolean; devOtp?: string }>(
/** '/auth/otp/request', { phone },
* گامِ ۲: تأییدِ کد. شماره‌ی تازه حسابِ تازه می‌سازد. );
*/ },
login: (phone: string, otp: string) => /** گامِ ۲ — نامِ فیلد در سرور `otp` است، نه `code`. */
http.post<T.AuthResponse>('/auth/login', { phone, otp }).then(saveTokens), async login(phone: string, otp: string) {
const data = await http.post<{ userId: string } & Tokens>(
/** '/auth/login', { phone, otp },
* حذفِ همیشگیِ حساب. کاربر کدِ تازه‌ی پیامکی می‌دهد (اول requestOtp با شماره‌ی خودش). );
* بعد از موفقیت توکن‌ها پاک می‌شوند؛ دیگر کاربری پشتشان نیست. http.storage.set({ token: data.token, refreshToken: data.refreshToken });
*/ return data;
deleteAccount: (body: { otp: string }) => },
http.post<{ success: boolean }>('/user/delete', body).then((r) => { logout() {
http.storage.set(null); http.storage.set(null);
return r; },
}), isSignedIn() {
return Boolean(http.storage.get()?.token);
/** خروج در سرور (باطل کردنِ refresh token) و بعد پاک کردنِ حافظه. */
async logout() {
// بدنه تابع است تا سرِ هر ارسال خوانده شود: اگر access token منقضی بوده، ۴۰۱ ← refresh ← تکرار، و
// refresh token عوض شده است؛ فرستادنِ نسخه‌ی قبلی نشستِ تازه را ۳۰ روز روی سرور باطل‌نشده می‌گذاشت.
const body = () => {
const refreshToken = http.storage.get()?.refreshToken;
return refreshToken ? { refreshToken } : {};
};
try {
await http.post('/auth/logout', body);
} finally {
http.storage.set(null);
}
}, },
}, },
grades: () => http.get<{ grades: T.Grade[] }>('/grades').then((r) => r.grades), /* ── کاربر ────────────────────────────────────────────────────────── */
profile: { profile: {
get: () => http.get<T.UserProfile>('/user/profile'), get: (opts?: RequestOptions) => http.get<UserProfile>('/user/profile', opts),
update: (patch: Partial<Pick<T.UserProfile, 'name' | 'avatar' | 'birthYear' | 'birthDate' | 'bio'>>) => /**
http.patch<T.UserProfile>('/user/profile', patch), * فقط فیلدهایی که می‌فرستید عوض می‌شوند. صفحه‌ی ثبت‌نام سن می‌پرسد، پس
* `age` هم پذیرفته می‌شود و سرور خودش به سالِ تولدِ شمسی تبدیلش می‌کند —
* `age` و `birthYear` را با هم نفرستید.
*/
update: (patch: Partial<Pick<UserProfile,
'name' | 'avatar' | 'birthYear' | 'parentPhone' | 'bio'>> & { age?: number }) =>
http.patch<UserProfile>('/user/profile', patch),
setGrade: (gradeId: number) => setGrade: (gradeId: number) =>
http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }), http.post<{ success: boolean; gradeId: number }>('/user/grade', { gradeId }),
}, },
summary: () => http.get<T.UserSummary>('/user/summary'), /** xp/سکه/استریک اینجاست، نه در profile. */
hearts: () => http.get<T.Hearts>('/user/hearts'), summary: (opts?: RequestOptions) =>
streakCalendar: () => http.get<T.StreakCalendar>('/user/streak/calendar'), http.get<Record<string, unknown>>('/user/summary', opts),
/** «گزارشِ والدین»: ۷ روزِ اخیرِ همین حساب در کنارِ ۷ روزِ قبلش. */
weeklyReport: () => http.get<T.WeeklyReport>('/user/weekly-report'),
/** /** پایه‌ها را هاردکد نکنید — اضافه می‌شوند. */
* بدونِ grade، پایه‌ی ذخیره‌شده‌ی خودِ کاربر ملاک است. بدونِ subject اولین کتابِ پایه؛ grades: (opts?: RequestOptions) =>
* کتابِ ناموجود در پایه ۴۰۰ می‌دهد (برای کلیدِ ذخیره‌شده‌ی قدیمی از `fetchHome` استفاده کنید). http.get<{ grades: Grade[] }>('/grades', opts).then((r) => r.grades),
*/
home: (subject?: string) => http.get<T.HomeResponse>(`/home${q({ subject })}`),
nodeLessons: (nodeId: string) => http.get<T.NodeLessons>(`/nodes/${seg(nodeId)}/lessons`),
/** /** بدونِ gradeId، پایه‌ی ذخیره‌شده‌ی خودِ کاربر استفاده می‌شود. */
* آزمونِ جزیره: از هر درسِ جزیره حداکثر یک سوال (پیش‌فرض ۴). ترتیب برای هر home: (gradeId?: number, opts?: RequestOptions) =>
* کاربر ثابت است، پس رفرش همان آزمون را می‌دهد. http.get<HomeResponse>(gradeId ? `/home?gradeId=${gradeId}` : '/home', opts),
*/
nodeQuiz: (nodeId: string) => http.get<T.NodeQuiz>(`/nodes/${seg(nodeId)}/quiz`),
/** پایانِ آزمون، جزیره را با هر نمره‌ای تمام می‌کند و جزیره‌ی بعدی را باز. */
nodeQuizComplete: (nodeId: string, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.NodeQuizComplete>(`/nodes/${seg(nodeId)}/quiz/complete`, result, { idempotencyKey }),
lesson: { /* ── درس و آزمون ──────────────────────────────────────────────────── */
intro: (lessonId: number) => http.get<T.LessonIntro>(`/lessons/${seg(lessonId)}/intro`), lessons: {
questions: (lessonId: number) => intro: (lessonId: number | string, opts?: RequestOptions) =>
http.get<{ questions: T.Question[] }>(`/lessons/${seg(lessonId)}/questions`).then((r) => r.questions), http.get<LessonIntro>(`/lessons/${lessonId}/intro`, opts),
/** questions: (lessonId: number | string, opts?: RequestOptions) =>
* کلیدِ یکتا را یک‌بار برای هر جواب بسازید و در تکرار همان را بفرستید تا http.get<{ questions: Question[] }>(`/lessons/${lessonId}/questions`, opts)
* امتیاز و قلب دوبار حساب نشود. .then((r) => r.questions),
*/ complete: (lessonId: number | string) =>
answer: (questionId: string, selectedOptionId: string, idempotencyKey = newIdempotencyKey()) => http.create<Record<string, unknown>>(`/lessons/${lessonId}/complete`, {}),
http.post<T.AnswerResult>(
`/questions/${seg(questionId)}/answer`,
{ selectedOptionId: Number(selectedOptionId) },
{ idempotencyKey, retry: true },
),
/** مجموعِ XP و سکه‌ی کاربر اینجا به‌روز می‌شود، نه سرِ هر جواب. */
complete: (lessonId: number, result: { correctCount: number; totalQuestions: number }, idempotencyKey?: string) =>
http.create<T.LessonComplete>(`/lessons/${seg(lessonId)}/complete`, result, { idempotencyKey }),
report: (questionId: string, reason: T.ReportReason, note?: string) =>
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>(
`/questions/${seg(questionId)}/report`,
{ reason, ...(note ? { note } : {}) },
),
}, },
/** لایک/دیسلایک و گزارشِ ایراد روی سوال و درس (۰ = پس‌گرفتنِ رأی). */ nodes: {
feedback: { /**
vote: (kind: 'question' | 'lesson', id: string, value: 1 | -1 | 0) => * قدم‌های یک مبحث برای نقشه‌ی مهارت — عنوان، نوع، زیرنویس و «انجام شد».
http.post<{ value: number }>(`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/vote`, { value }), * `home()` فقط شناسه‌ی درس‌ها را می‌دهد؛ بدونِ این، برای هر کارت یک
report: (kind: 'question' | 'lesson', id: string, reason: string, note?: string) => * درخواستِ intro جدا لازم بود.
http.post<{ success: boolean; reportId: number; alreadyReported: boolean }>( */
`/${kind === 'question' ? 'questions' : 'lessons'}/${seg(id)}/report`, lessons: (nodeId: number | string, opts?: RequestOptions) =>
{ reason, ...(note ? { note } : {}) }, http.get<NodeLessons>(`/nodes/${nodeId}/lessons`, opts),
),
}, },
leaderboard: (period: T.LeaderboardPeriod) => questions: {
http.get<{ period: string; entries: T.LeaderboardEntry[] }>(`/leaderboard${q({ period })}`).then((r) => r.entries), /**
* ثبتِ پاسخ. مسیر زیرِ /questions است نه /lessons، و نامِ فیلد
* `selectedOptionId` است. کلیدِ یکتا می‌گیرد تا دابل‌کلیکِ کاربر یا
* تکرارِ خودکارِ شبکه دو بار XP ندهد و دو بار «قلب» کم نکند.
*/
answer: (questionId: number | string, selectedOptionId: number | string) =>
http.create<AnswerResponse>(`/questions/${questionId}/answer`, { selectedOptionId }),
characters: { /** «این سوال ایراد دارد» — گزارشِ تکراریِ همان کاربر ردیفِ تازه نمی‌سازد. */
list: () => http.get<{ characters: T.Character[] }>('/characters').then((r) => r.characters), report: (questionId: number | string, reason: ReportReason, note = '') =>
select: (characterId: string) => http.post<ReportResponse>(`/questions/${questionId}/report`, { reason, note }),
http.post<{ characterId: string; selected: boolean }>('/characters/select', { characterId }),
}, },
/* ── دستیار ───────────────────────────────────────────────────────── */
ai: { ai: {
/** «امروز X سوالِ رایگان + Y سوالِ خریده‌شده»، پیش از اولین سوال. */
quota: () => http.get<T.TutorQuota>('/ai/quota'),
/** /**
* پرسش با پاسخِ تکه‌تکه. `lessonId` پاسخ را به کتابِ همان درس مستند می‌کند؛ * فقط `lessonId` کافی‌ست: سرور خودش می‌فهمد این درس مالِ کدام پایه و
* `questionId` به دستیار می‌گوید دانش‌آموز کدام سوال را غلط زده. * فصل است و پاسخ را به کتاب مستند می‌کند.
*
* ⚠️ نامِ فیلدِ پرسش `userQuestion` است، نه `question`.
*/ */
async ask( ask: (
input: { userQuestion: string,
userQuestion: string; conversationId?: string; lessonId?: number; questionId?: string; ctx: { lessonId?: number | string; conversationId?: string; gradeId?: number } = {},
/** نامِ کتاب («هندسه»)؛ برای پرسشِ بی‌درس تا دستیار از کتابِ همان انتخابِ کاربر جواب بدهد. */ opts?: RequestOptions,
subject?: string; ) =>
}, http.post<AskResponse>('/ai/ask', { userQuestion, ...ctx }, {
onDelta: (text: string) => void, timeoutMs: ASK_TIMEOUT_MS,
signal?: AbortSignal, ...opts,
): 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/${seg(messageId)}/feedback`, {
isHelpful,
...(reason ? { reason } : {}),
}), }),
history: (conversationId: string) =>
http.get<{ conversationId: string; lessonId: string | null; messages: T.ConversationMessage[] }>( /** ⚠️ `isHelpful` بولین است، نه رشته‌ی up/down. `reason` اختیاری. */
`/ai/conversations/${seg(conversationId)}/messages`, feedback: (messageId: number | string, isHelpful: boolean, reason?: string) =>
http.post<{ success: boolean }>(
`/ai/messages/${messageId}/feedback`, { isHelpful, reason },
), ),
}, },
notifications: { /* ── اشتراک ───────────────────────────────────────────────────────── */
list: () => http.get<{ items: T.NotificationItem[]; unreadCount: number }>('/notifications'), plans: (opts?: RequestOptions) =>
markRead: (id: number) => http.patch(`/notifications/${seg(id)}/read`), http.get<{ plans: Plan[] }>('/subscription/plans', opts).then((r) => r.plans),
markAllRead: () => http.patch('/notifications/read-all'), };
}, }
subscription: { export type Api = ReturnType<typeof createApi>;
plans: () => http.get<T.PlansResponse>('/subscription/plans'),
status: () => http.get<T.SubscriptionStatus>('/subscription/status'),
/** سفارش می‌سازد؛ paymentUrl خودش سراغِ درگاه می‌رود. */
purchase: (planId: string) => http.create<T.PurchaseResponse>('/subscription/purchase', { planId }),
/**
* خرید داخلِ اپِ اندرویدِ بازار. idempotencyKey ثابت است تا تلاشِ دوباره بعد از قطعِ اینترنت
* همان سفارش را برگرداند (src/lib/bazaar.ts).
*/
purchaseBazaar: (planId: string, idempotencyKey: string) =>
http.create<T.PurchaseResponse>('/subscription/purchase', { planId, paymentMethod: 'bazaar' }, { idempotencyKey }),
/** SKU، نوعِ محصول و (اگر تخفیف خورده) قیمتِ امضاشده برای بازار؛ بعد از آن کدِ تخفیف نمی‌نشیند. */
startBazaar: (orderId: string) => http.post<T.BazaarStartResponse>(`/payment/bazaar/orders/${seg(orderId)}/start`),
/** توکنِ خریدِ Poolakey را سرور از خودِ بازار می‌سنجد؛ گفته‌ی اپ کافی نیست. */
verifyBazaar: (orderId: string, purchaseToken: string) =>
http.post<T.BazaarVerifyResponse>('/payment/bazaar/verify', { orderId, purchaseToken }),
/** کدِ تخفیف باید پیش از رفتن به درگاه ثبت شود؛ هر سفارش فقط یک کد. */
redeem: (code: string, orderId: string) =>
http.post<T.RedeemResponse>('/discount/redeem', { code, orderId }),
order: (orderId: string) => http.get<T.OrderStatus>(`/payment/orders/${seg(orderId)}`),
/** پرداخت‌ها و فاکتورهای همین کاربر، تازه‌ترین اول. */
invoices: () => http.get<{ invoices: T.Invoice[] }>('/payment/invoices').then((r) => r.invoices),
},
support: { /** نمونه‌ی پیش‌فرض — برای حالتِ عادی همین کافی‌ست. */
createTicket: (subject: string, category: string) => export const api = createApi();
http.create<{ ticketId: string; status: string }>('/support/tickets', { subject, category }),
/** تیکت‌های همین کاربر، تازه‌ترین اول (۲۰ تای آخر). */
tickets: () => http.get<{ tickets: T.TicketSummary[] }>('/support/tickets').then((r) => r.tickets),
ticket: (ticketId: string) => http.get<T.Ticket>(`/support/tickets/${seg(ticketId)}`),
reply: (ticketId: string, text: string) =>
http.create<{ messageId: string; success: boolean }>(`/support/tickets/${seg(ticketId)}/messages`, { text }),
bugReport: (title: string, description: string) =>
http.create<{ bugReportId: string; status: string }>('/support/bug-reports', { title, description }),
},
affiliate: {
me: () => http.get<T.AffiliateMe>('/affiliate/me'),
/** کاربرِ تازه را به صاحبِ کدِ معرف وصل می‌کند. */
track: (referralCode: string) => http.post<{ success: boolean }>('/affiliate/track', { referralCode }),
},
};

View File

@@ -1,78 +1,29 @@
/** /**
* شکلِ پاسخ‌های بک‌اند. * تایپ‌های پاسخِ بک‌اند — مستقیم از OpenAPI واقعیِ سرویس، نه از حدس.
* *
* از روی پاسخِ واقعیِ سرور ثبت شده (۲۰۲۶-۰۹-۲۶)، نه از حدس. قراردادِ کامل در * دو نکته که موقعِ نوشتن غافلگیرکننده بودند و اگر رعایت نشوند درخواست‌ها
* /api/docs/ همان بک‌اند است؛ اگر فیلدی آنجا عوض شد، اینجا هم عوض کنید. * بی‌سروصدا شکست می‌خورند:
* * • شناسه‌ها در پاسخ‌ها اغلب رشته‌اند، نه عدد.
* دو نکته که غافلگیر می‌کنند: * • فیلدِ پرسش در /ai/ask اسمش userQuestion است، نه question.
* • شناسه‌ها در پاسخ‌ها رشته‌اند، ولی در بدنه‌ی درخواست گاهی عدد خواسته می‌شوند.
* • birthYear سالِ شمسی است (سرور بیشتر از ۱۵۰۰ را رد می‌کند).
*/ */
export interface AuthResponse { export type NodeStatusValue = 'locked' | 'unlocked' | 'in_progress' | 'completed';
userId: string;
token: string;
refreshToken: string;
isNewUser?: boolean;
}
export interface Grade {
id: number;
number: number | null;
title: string;
icon: string;
color: string;
lessonCount: number;
}
export interface UserProfile {
userId: string;
/** شناسه‌ی نمایشی «User482917»؛ همان که پشتیبانی در ادمین جستجو می‌کند. */
publicId: string;
phone: string | null;
name: string;
avatar: string;
gradeId: number | null;
birthYear: number | null;
/** تاریخِ تولدِ شمسی «1385-04-12»؛ null وقتی فقط سال داده شده یا هیچ. */
birthDate: string | null;
bio: string;
}
export interface UserSummary {
userId: string;
name: string;
avatar: string;
gradeId: number | null;
xp: number;
coins: number;
/**
* activeToday: امروز تمرین شده (آتش روشن). lost: طولِ زنجیره‌ای که دیروز شکست (> ۰ یعنی «یخ‌زده»).
*/
streak: { current: number; longest: number; activeToday: boolean; lost: number };
subscription: string | null;
}
export type NodeStatus = 'locked' | 'unlocked' | 'in_progress' | 'completed';
export interface HomeNode { export interface HomeNode {
id: string; id: string;
title: string; title: string;
/** نامِ آیکنِ lucide (مثلاً "Grid3x3")؛ ممکن است خالی باشد. */ /** آیکون و رنگِ کارتِ مبحث — از سرور می‌آید، هاردکد نکنید. */
icon: string; icon: string;
color: string; color: string;
status: NodeStatus; status: NodeStatusValue;
/** درس‌های سوال‌دارِ این جزیره؛ اولی همیشه درسِ بعدی است. */
lessonIds: number[]; lessonIds: number[];
nextLessonId: number | null;
} }
export interface HomeUnit { export interface HomeUnit {
id: string; id: string;
title: string; title: string;
/** یک جمله‌ی کوتاه زیرِ عنوانِ فصل. */
blurb: string; blurb: string;
/** این کاربر برای بازیِ این فصل اشتراک لازم دارد (فقط فصلِ اولِ هر پایه رایگان است). */
requiresSubscription: boolean;
nodes: HomeNode[]; nodes: HomeNode[];
} }
@@ -82,30 +33,57 @@ export interface HomeStats {
coins: number; coins: number;
} }
/** یک کتابِ ریاضیِ پایه (ریاضی، حسابان، هندسه، آمار و احتمال، گسسته). */
export interface Subject {
/** همان که به `api.home(subject)` داده می‌شود. */
key: string;
title: string;
/** نامِ آیکنِ lucide. */
icon: string;
color: string;
order: number;
}
export interface HomeResponse { export interface HomeResponse {
/** کتابی که `units` از آن است؛ null فقط وقتی پایه فصلی ندارد. */
subject: Subject | null;
/** همه‌ی کتاب‌های این پایه؛ نوارِ کتاب‌ها فقط با بیش از یکی نشان داده می‌شود. */
subjects: Subject[];
units: HomeUnit[]; units: HomeUnit[];
stats: HomeStats; stats: HomeStats;
} }
export interface Grade {
id: number;
title: string;
icon: string;
color: string;
/** صفر یعنی این پایه هنوز محتوایی ندارد — گزینه‌اش را غیرفعال کنید. */
lessonCount: number;
}
/** آمارِ بازی (xp/سکه/استریک) اینجا نیست — از /user/summary می‌آید. */
export interface UserProfile {
userId: string;
phone: string;
name: string;
avatar: string;
isGuest: boolean;
gradeId: number | null;
birthYear: number | null;
parentPhone: string;
bio: string;
}
/** یک قدم در مسیر: آزمون یا ویدیو. */
export type LessonKind = 'quiz' | 'video';
export interface LessonIntro {
lessonId: string;
title: string;
kind: LessonKind;
/** متنِ درس سه تکه است: توضیح، مثال، و حلِ گام‌به‌گام. */
introText: string;
example: string;
steps: string[];
/** فقط وقتی kind === 'video' پر است. */
videoUrl: string;
durationMinutes: number | null;
mascotAnimation: string;
questionCount: number;
}
/** یک کارت در نقشه‌ی مهارت. */
export interface NodeLesson { export interface NodeLesson {
id: string; id: string;
kind: 'quiz' | 'video'; kind: LessonKind;
title: string; title: string;
/** زیرنویسِ کارت: «۵ سوال» یا «۱۲ دقیقه». */
meta: string; meta: string;
done: boolean; done: boolean;
} }
@@ -116,30 +94,7 @@ export interface NodeLessons {
lessons: NodeLesson[]; lessons: NodeLesson[];
} }
export interface LessonIntro { export interface ChoiceOption {
lessonId: string;
title: string;
introText: string;
mascotAnimation: string;
/** صفر یعنی متنِ درس هست ولی سوالش هنوز ساخته نشده. */
questionCount: number;
kind: 'quiz' | 'video';
example: string;
steps: string[];
/** شکل‌های ثابتِ درس (یک بار ساخته و تأییدشده؛ هیچ تولیدِ زنده‌ای نیست). */
figures: LessonFigure[];
videoUrl: string;
durationMinutes: number | null;
}
export interface LessonFigure {
where: 'intro' | 'example' | 'step';
/** فقط برای where='step'، از صفر. */
index?: number;
diagram: DiagramSpec;
}
export interface QuestionOption {
id: string; id: string;
value: string; value: string;
} }
@@ -148,37 +103,11 @@ export interface Question {
id: string; id: string;
type: string; type: string;
prompt: string; prompt: string;
options: QuestionOption[]; options: ChoiceOption[];
/** شکلِ سوال (همان قالبِ شکلِ درس‌ها)؛ null یعنی شکل ندارد. */
figure?: DiagramSpec | null;
} }
export interface NodeQuizQuestion extends Question { /** `mascotMood`: پاسخِ درست → happy، غلط → encourage. */
lessonId: string; export interface AnswerResponse {
lessonTitle: string;
}
export interface NodeQuiz {
nodeId: string;
nodeTitle: string;
unitTitle: string;
/** ممکن است از requested کمتر باشد (جزیره‌ی کم‌سوال). */
questionCount: number;
requested: number;
questions: NodeQuizQuestion[];
}
export interface NodeQuizComplete {
nodeId: string;
stars: number;
xpEarned: number;
coinsEarned: number;
/** جزیره‌ی بعدی که حالا باز است؛ null یعنی آخرینِ پایه بود. */
nextNodeId: string | null;
stats: HomeStats;
}
export interface AnswerResult {
correct: boolean; correct: boolean;
correctOptionId: string; correctOptionId: string;
xpEarned: number; xpEarned: number;
@@ -186,241 +115,40 @@ export interface AnswerResult {
mascotMood: string; 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 { export interface UsedChunk {
title: string; title: string;
summary: string; summary: string;
} }
/** رویدادِ پایانیِ استریمِ دستیار. */ /**
export interface AskDone { * `usedChunks` خالی یعنی پاسخ از کتاب پیدا نشده و عمومی است — برای نشان دادنِ
* برچسبِ «بر اساسِ کتاب» در برابرِ «پاسخِ عمومی» همین کافی‌ست.
*/
export interface AskResponse {
conversationId: string; conversationId: string;
messageId: string; messageId: string;
answer: string;
suggestedFollowUps: string[]; suggestedFollowUps: string[];
/** سوال‌های رایگانِ باقیِ امروز. */
remainingQuota: number; remainingQuota: number;
usedChunks: UsedChunk[]; usedChunks: UsedChunk[];
diagram?: Diagram | null; retrievalDebug?: Record<string, unknown>;
}
export interface ConversationMessage {
messageId: string;
role: 'user' | 'assistant' | string;
text: string;
diagram?: Diagram | null;
}
/* ─── شکلِ پاسخِ دستیار: شِمایش در src/diagram/types.ts ─────────────────── */
import type { DiagramSpec } from '@/diagram/types';
export type Diagram = DiagramSpec;
export interface NotificationItem {
id: number;
type: string;
title: string;
body: string;
isRead: boolean;
createdAt: string;
} }
export interface Plan { export interface Plan {
id: string; id: string;
title: string; title: string;
/** تومان (currency = "IRT") */
price: number; price: number;
currency: string; currency: string;
popular: boolean; popular: boolean;
discountPercent: number; discountPercent: number;
durationDays: number; durationDays: number;
features: string[];
badge: string;
/** سوال در روز از دستیار برای مشترکِ این پلن (سرور: Plan.ai_daily_quota). */
aiDailyQuota: number;
/** SKUِ همین اشتراک در کافه‌بازار؛ خالی یعنی از بازار خریدنی نیست. */
bazaarSku: string;
} }
export interface PlansResponse { export type ReportReason =
plans: Plan[]; | 'wrong_answer' | 'unclear' | 'typo' | 'too_hard' | 'other';
/** سقفِ روزانه‌ی کاربرِ بی‌اشتراک، برای مقایسه روی کارت. */
freeAiDailyQuota: number;
}
export interface SubscriptionStatus { export interface ReportResponse {
/** اشتراکِ زمانیِ فعال (یک/سه/شش‌ماهه). */
active: boolean;
/** نامِ پلنِ فعال؛ null بی‌اشتراک. */
planTitle: string | null;
/** طولِ دوره‌ی پلنِ فعال به روز؛ مخرجِ نوارِ «چقدر مانده». */
planDurationDays: number | null;
/** سقفِ سوالِ روزانه‌ی همین کاربر از دستیار. */
aiDailyQuota: number;
planId: string | null;
expiresAt: string | null;
}
/** GET /ai/quota */
export interface TutorQuota {
freeDaily: number;
remainingQuota: number;
}
export interface PurchaseResponse {
orderId: string;
/** زرین‌پال: آدرسِ درگاه. بازار: null. */
paymentUrl: string | null;
/** فقط بازار: SKUی که اپِ اندروید می‌خرد. */
bazaarSku?: string;
}
/** POST /payment/bazaar/orders/{id}/start — بعد از کدِ تخفیف، درست پیش از صفحه‌ی خریدِ بازار. */
export interface BazaarStartResponse {
orderId: string;
sku: string;
/** مبلغِ سفارش (تومان)، پس از کدِ تخفیف. */
amount: number;
/** JWTِ «تخفیف پویا»؛ null یعنی بازار قیمتِ پیشخان را می‌گیرد. */
dynamicPriceToken: string | null;
}
export interface BazaarVerifyResponse {
orderId: string;
status: string;
valid: boolean;
}
export interface RedeemResponse {
success: boolean; success: boolean;
discountAmount: number; reportId: number;
/** "paid" یعنی کد کلِ مبلغ را پوشاند و نباید به درگاه رفت. */ alreadyReported: boolean;
orderStatus: string;
amount: number;
}
export interface OrderStatus {
orderId: string;
status: string;
amount: number;
}
/** یک ردیف از فهرستِ تیکت‌های کاربر (GET /support/tickets). */
export interface TicketSummary {
ticketId: string;
subject: string;
category: string;
status: string;
createdAt: string;
supportReplies: number;
}
export interface Ticket {
ticketId: string;
subject: string;
status: string;
messages: { id: string; sender: string; text: string }[];
}
/** یک سفارش به شکلِ فاکتور (GET /payment/invoices). مبلغ‌ها تومان‌اند. */
export interface Invoice {
orderId: string;
invoiceNumber: string;
/** «canceled» و «refunded» را فقط ادمین می‌گذارد (لغو و بازپرداخت، از ۱۴۰۵-۰۷). */
status: 'pending' | 'paid' | 'failed' | 'canceled' | 'refunded';
provider: 'gateway' | 'bazaar' | string;
planTitle: string;
/** قیمتِ پایه پیش از تخفیف؛ null برای سفارش‌های قدیمی‌تر از ثبتِ فاکتور. */
listPrice: number | null;
discountAmount: number | null;
discountCode: string;
amount: number;
currency: string;
createdAt: string;
paidAt: string | null;
/** کدِ پیگیریِ بانکی/بازار؛ خالی برای پرداخت‌نشده یا کدِ تخفیفِ ۱۰۰٪. */
refId: string;
}
/** یک روز از گزارشِ هفتگی. `minutes` تخمینِ سرور است (از فاصله‌ی جواب‌ها)، نه زمانِ باز بودنِ اپ. */
export interface ReportDay {
date: string;
minutes: number;
questions: number;
correct: number;
xp: number;
lessons: number;
islands: number;
aiQuestions: number;
}
export interface ReportTotals {
minutes: number;
activeDays: number;
questions: number;
correct: number;
xp: number;
/** یکتا: درس یا جزیره‌ی تکراری یک بار شمرده می‌شود. */
lessons: number;
islands: number;
aiQuestions: number;
}
/** GET /user/weekly-report: هفت روزِ اخیر و هفت روزِ قبلش، هر دو قدیمی به جدید. */
export interface WeeklyReport {
today: string;
days: ReportDay[];
previousDays: ReportDay[];
thisWeek: ReportTotals;
lastWeek: ReportTotals;
streak: { current: number; longest: number };
}
export interface AffiliateMe {
referralCode: string;
commissionRate: number;
referredCount: number;
paidCount: number;
totalCommission: number;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,382 +0,0 @@
import { lazy, Suspense, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
import { motion } from 'motion/react';
import { CheckCircle2, XCircle } from 'lucide-react';
import { api, type Grade, type UserProfile } from '@/api';
import { AppDataProvider, useAppData, type Prefetch } from '@/state/AppData';
import { NavigatorProvider, useNav, type NavDirection } from '@/nav/Navigator';
import { ScreenGateContext, type ScreenGate } from '@/lib/useAsync';
import { isTab, type Route } from '@/nav/routes';
import { BottomNav } from '@/components/BottomNav';
import { Sheet } from '@/components/Sheet';
import { LoadingState, PrimaryButton } from '@/components/ui';
import { HomeScreen } from '@/screens/HomeScreen';
import type { PaymentReturn } from './launchParams';
/**
* خانه همراهِ خودِ اپ بار می‌شود؛ بقیه‌ی صفحه‌ها هر کدام فایلِ جدا دارند تا بارِ اول
* سبک باشد (قبلاً کلِ اپ یک فایلِ ۵۰۵ کیلوبایتی بود، و دستیار با ماژولِ شکل‌ها
* بزرگ‌ترین تکه‌اش). بعد از بالا آمدنِ خانه همه در پس‌زمینه گرفته می‌شوند
* (preloadScreens)، پس رفتن به صفحه‌ی بعدی منتظرِ شبکه نمی‌ماند.
*/
const loaders: (() => Promise<unknown>)[] = [];
function screen<M extends Record<K, ComponentType<any>>, K extends keyof M>(load: () => Promise<M>, name: K) {
loaders.push(load);
return lazy(async () => ({ default: (await load())[name] }));
}
const AskScreen = screen(() => import('@/screens/AskScreen'), 'AskScreen');
const CharactersScreen = screen(() => import('@/screens/CharactersScreen'), 'CharactersScreen');
const InviteScreen = screen(() => import('@/screens/InviteScreen'), 'InviteScreen');
const InvoicesScreen = screen(() => import('@/screens/InvoicesScreen'), 'InvoicesScreen');
const LeaderboardScreen = screen(() => import('@/screens/LeaderboardScreen'), 'LeaderboardScreen');
const LessonScreen = screen(() => import('@/screens/LessonScreen'), 'LessonScreen');
const LessonsScreen = screen(() => import('@/screens/LessonsScreen'), 'LessonsScreen');
const NotificationsScreen = screen(() => import('@/screens/NotificationsScreen'), 'NotificationsScreen');
const ProfileScreen = screen(() => import('@/screens/ProfileScreen'), 'ProfileScreen');
const ReportScreen = screen(() => import('@/screens/ReportScreen'), 'ReportScreen');
const QuizScreen = screen(() => import('@/screens/QuizScreen'), 'QuizScreen');
const StreakScreen = screen(() => import('@/screens/StreakScreen'), 'StreakScreen');
const SubscriptionScreen = screen(() => import('@/screens/SubscriptionScreen'), 'SubscriptionScreen');
const SupportScreen = screen(() => import('@/screens/SupportScreen'), 'SupportScreen');
const UnitScreen = screen(() => import('@/screens/UnitScreen'), 'UnitScreen');
let preloaded = false;
function preloadScreens() {
if (preloaded) return;
preloaded = true;
const run = () => loaders.forEach((load) => void load().catch(() => {}));
if ('requestIdleCallback' in window) window.requestIdleCallback(run, { timeout: 2500 });
else setTimeout(run, 1200);
}
interface Props {
profile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
/** داده‌ها از عکسِ خانه روی گوشی آمده‌اند و پشتِ صحنه تازه می‌شوند (AppData). */
stale?: boolean;
onIdentityChanged?: () => void;
payment: PaymentReturn | null;
onSignedOut: () => void;
}
/** اپِ اصلی بعد از ورود: تب‌ها، صفحه‌ها و داده‌ی مشترک. */
export function MainApp({ profile, grades, prefetch, stale, onIdentityChanged, payment, onSignedOut }: Props) {
useEffect(preloadScreens, []);
return (
<AppDataProvider
initialProfile={profile}
grades={grades}
prefetch={prefetch}
stale={stale}
onIdentityChanged={onIdentityChanged}
>
<NavigatorProvider initial={{ name: payment ? 'subscription' : 'home' }}>
<Screens onSignedOut={onSignedOut} />
{payment && <PaymentResult payment={payment} />}
</NavigatorProvider>
</AppDataProvider>
);
}
/**
* صحنه‌ی صفحه‌ها. صفحه‌ی تازه اول پنهان زیرِ صفحه‌ی فعلی ساخته می‌شود و فقط وقتی داده‌ی اولش رسید نشان داده
* می‌شود (ScreenGate در useAsync)، با یک حرکتِ کوتاه. قبلاً صفحه همان لحظه نیمه‌کاره می‌آمد (کارتِ خالی، «نداری»،
* عددِ صفر) و چند صد میلی‌ثانیه بعد داده‌ی واقعی جایش می‌پرید؛ همان «چشمک»ی که روی گوشی دیده می‌شد.
*
* اگر جواب کند باشد، بعد از HOLD_MAX_MS صفحه با لودرِ خودش می‌آید تا لمسِ کاربر بی‌جواب نماند؛ در این فاصله
* نوارِ باریکِ بالا نشان می‌دهد که کاری در جریان است. عددها از راهنمای رایجِ پاسخ‌گویی آمده‌اند (زیرِ ~۱۰۰ms
* «آنی»، تا ~۴۰۰ms هنوز پیوسته)؛ روی گوشیِ واقعی با شبکه‌ی ضعیف سنجیده نشده‌اند.
*/
const HOLD_MAX_MS = 450;
const HOLD_BAR_AFTER_MS = 120;
interface Layer {
id: number;
route: Route;
direction: NavDirection;
/** ریشه‌ی پشته است (تب)، نه صفحه‌ای که روی آن باز شده؛ نوارِ پایین فقط آن‌جا هست. */
root: boolean;
shown: boolean;
}
function Screens({ onSignedOut }: Pick<Props, 'onSignedOut'>) {
const nav = useNav();
const { route, direction, canGoBack } = nav;
const nextId = useRef(1);
// صفحه‌ی اولِ بعد از ورود بی‌انتظار و بی‌حرکت می‌آید (داده‌ی خانه پیش از آن در اسپلش گرفته شده).
const [layers, setLayers] = useState<Layer[]>(() => [{ id: 0, route, direction, root: true, shown: true }]);
useEffect(() => {
setLayers((prev) => {
if (prev[prev.length - 1].route === route) return prev;
// صفحه‌ای که هنوز نیامده و کاربر از آن گذشته، همان‌جا کنار می‌رود.
const visible = prev.filter((l) => l.shown);
return [...visible, { id: nextId.current++, route, direction, root: !canGoBack, shown: false }];
});
}, [route, direction, canGoBack]);
const reveal = useCallback((id: number) => {
setLayers((prev) => prev.map((l) => (l.id === id ? { ...l, shown: true } : l)));
}, []);
const settle = useCallback((id: number) => {
// صفحه‌ی تازه کامل آمد؛ هرچه زیرش است برداشته شود.
setLayers((prev) => {
const i = prev.findIndex((l) => l.id === id);
return i > 0 ? prev.slice(i) : prev;
});
}, []);
const top = [...layers].reverse().find((l) => l.shown) ?? layers[0];
// زدنِ دوباره‌ی تبِ باز: بخشِ اسکرول‌شونده‌ی صفحه‌ی دیده‌شده نرم به بالا می‌رود.
const stage = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!nav.reselect) return;
const layer = stage.current?.querySelector<HTMLElement>(`[data-layer="${top.id}"]`);
layer?.querySelectorAll<HTMLElement>('.overflow-y-auto').forEach((el) => el.scrollTo({ top: 0, behavior: 'smooth' }));
// فقط با زدنِ دوباره، نه با عوض شدنِ لایه.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nav.reselect]);
const waiting = !layers[layers.length - 1].shown;
// نوارِ پایین از صفحه‌ای که دیده می‌شود پیروی می‌کند، نه از صفحه‌ای که هنوز پنهان است؛ وگرنه ارتفاعِ صفحه‌ی
// فعلی پیش از رفتن عوض می‌شد و می‌پرید. تبِ روشن ولی همان لحظه‌ی لمس عوض می‌شود (بازخوردِ فوری).
const showNav = isTab(top.route.name) && top.root;
return (
<>
<div ref={stage} className="relative flex-1 overflow-hidden bg-page">
{layers.map((layer) => (
<ScreenLayer key={layer.id} layer={layer} onReady={reveal} onSettled={settle} onSignedOut={onSignedOut} />
))}
<HoldBar active={waiting} />
</div>
{showNav && <BottomNav active={nav.tab} onChange={nav.switchTab} />}
</>
);
}
const ENTER: Record<NavDirection, { opacity: number; x: number }> = {
// در راست‌به‌چپ صفحه‌ی بعدی از چپ می‌آید و برگشت از راست؛ تعویضِ تب فقط محو است، مثلِ اپ‌های بومی.
push: { opacity: 0, x: -28 },
replace: { opacity: 0, x: -28 },
back: { opacity: 0, x: 28 },
tab: { opacity: 0, x: 0 },
};
function ScreenLayer({ layer, onReady, onSettled, onSignedOut }: {
layer: Layer;
onReady: (id: number) => void;
onSettled: (id: number) => void;
} & Pick<Props, 'onSignedOut'>) {
const { id, shown } = layer;
const initiallyShown = useRef(shown).current;
const gate = useGate(initiallyShown ? null : () => onReady(id));
// اگر onAnimationComplete نرسید (مثلاً «کاهشِ حرکت» یا قطعِ انیمیشن)، صفحه‌های زیرین باز هم برداشته شوند.
useEffect(() => {
if (!shown || initiallyShown) return;
const t = setTimeout(() => onSettled(id), 700);
return () => clearTimeout(t);
}, [shown, initiallyShown, id, onSettled]);
return (
<ScreenGateContext.Provider value={gate}>
<motion.div
className="absolute inset-0 flex flex-col bg-page"
initial={initiallyShown ? false : ENTER[layer.direction]}
animate={shown ? { opacity: 1, x: 0 } : ENTER[layer.direction]}
transition={{ duration: layer.direction === 'tab' ? 0.14 : 0.2, ease: [0.2, 0.8, 0.2, 1] }}
onAnimationComplete={() => shown && onSettled(id)}
data-layer={id}
style={{ pointerEvents: shown ? 'auto' : 'none', zIndex: id }}
aria-hidden={!shown}
// صفحه‌ی پنهان نباید با Tab صفحه‌کلید فوکوس بگیرد. (React 18 «inert» را فقط به‌صورتِ رشته رد می‌کند.)
{...((!shown ? { inert: '' } : {}) as object)}
>
<Suspense fallback={<SuspenseHold />}>
<RouteView route={layer.route} onSignedOut={onSignedOut} />
</Suspense>
</motion.div>
</ScreenGateContext.Provider>
);
}
/**
* شمارنده‌ی holdهای صفحه. وقتی بعد از mount هیچ holdی باز نماند، یا HOLD_MAX_MS گذشت، onReady یک بار صدا
* زده می‌شود. React افکت‌های فرزند را پیش از پدر اجرا می‌کند، پس تا افکتِ این‌جا همه‌ی useAsyncهای صفحه
* hold گرفته‌اند.
*/
function useGate(onReady: (() => void) | null): ScreenGate | null {
const readyRef = useRef(onReady);
readyRef.current = onReady;
const state = useRef({ count: 0, mounted: false, done: onReady === null });
const fire = useCallback(() => {
const s = state.current;
if (s.done) return;
s.done = true;
readyRef.current?.();
}, []);
const gate = useMemo<ScreenGate | null>(
() =>
onReady === null
? null
: {
hold() {
const s = state.current;
if (s.done) return () => {};
s.count++;
let released = false;
return () => {
if (released) return;
released = true;
s.count--;
// یک microtask صبر: در StrictMode (و جابه‌جاییِ Suspense) افکت‌ها پاک و دوباره اجرا می‌شوند و
// hold تازه بلافاصله گرفته می‌شود؛ بی‌این صبر دروازه پیش از رسیدنِ داده باز می‌شد.
queueMicrotask(() => {
if (s.mounted && s.count === 0) fire();
});
};
},
},
// فقط بارِ اول؛ لایه‌ای که یک بار نشان داده شد دیگر دروازه ندارد.
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
useEffect(() => {
const s = state.current;
if (s.done) return;
s.mounted = true;
if (s.count === 0) {
fire();
return;
}
const t = setTimeout(fire, HOLD_MAX_MS);
return () => {
s.mounted = false;
clearTimeout(t);
};
}, [fire]);
return gate;
}
/** تا فایلِ صفحه از شبکه نرسیده، دروازه بسته می‌ماند (معمولاً از قبل در پس‌زمینه گرفته شده). */
function SuspenseHold() {
const gate = useContext(ScreenGateContext);
useEffect(() => {
const release = gate?.hold();
// آزاد کردن خودش یک microtask صبر می‌کند (useGate)، پس useAsyncهای صفحه‌ی تازه پیش از آن hold گرفته‌اند.
return () => release?.();
}, [gate]);
return <LoadingState />;
}
/** نوارِ باریکِ بالای صفحه وقتی صفحه‌ی بعدی بیش از HOLD_BAR_AFTER_MS منتظرِ داده است. */
function HoldBar({ active }: { active: boolean }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return;
}
const t = setTimeout(() => setVisible(true), HOLD_BAR_AFTER_MS);
return () => clearTimeout(t);
}, [active]);
return (
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 z-[9999] h-[3px] overflow-hidden">
{visible && <div className="hold-bar h-full w-1/3 rounded-full bg-white/90 shadow-[0_0_6px_rgba(237,105,86,.8)]" />}
</div>
);
}
function RouteView({ route, onSignedOut }: { route: Route } & Pick<Props, 'onSignedOut'>) {
switch (route.name) {
case 'home': return <HomeScreen />;
case 'lessons': return <LessonsScreen />;
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 'report': return <ReportScreen />;
case 'invoices': return <InvoicesScreen />;
case 'invite': return <InviteScreen />;
}
}
/**
* نتیجه‌ی برگشت از درگاه. فقط پاسخِ سرور تعیین می‌کند پرداخت موفق بوده یا نه؛ `status` در آدرس را هر
* لینکِ ساختگی می‌تواند «paid» بگذارد.
*/
/** سرور سفارشِ pending را دقیقه‌ای یک بار از درگاه می‌پرسد؛ ۴ بار با فاصله‌ی ۲۰ ثانیه حدودِ یک دقیقه است. */
const PENDING_CHECKS = 4;
const PENDING_CHECK_MS = 20_000;
function PaymentResult({ payment }: { payment: PaymentReturn }) {
const { refreshSummary } = useAppData();
const [open, setOpen] = useState(true);
const [state, setState] = useState<'checking' | 'paid' | 'failed' | 'unknown'>('checking');
useEffect(() => {
// pending یعنی تأییدِ درگاه هنوز نرسیده (مثلاً verify هنگامِ برگشت جواب نداد)، نه «پرداخت نشد»: سرور با هر
// خواندنِ وضعیت دوباره از زرین‌پال می‌پرسد (حداکثر دقیقه‌ای یک بار)، پس چند بار با فاصله می‌پرسیم.
let alive = true;
let timer: ReturnType<typeof setTimeout> | undefined;
const check = (attempt: number) => {
api.subscription
.order(payment.orderId)
.then((order) => {
if (!alive) return;
if (order.status === 'paid') {
setState('paid');
void refreshSummary();
} else if (order.status === 'pending' && attempt < PENDING_CHECKS) {
timer = setTimeout(() => check(attempt + 1), PENDING_CHECK_MS);
} else {
setState(order.status === 'pending' ? 'unknown' : 'failed');
}
})
.catch((err: unknown) => {
console.warn('payment result lookup failed', err);
if (alive) setState('unknown');
});
};
check(1);
void refreshSummary();
return () => {
alive = false;
clearTimeout(timer);
};
}, [payment.orderId, refreshSummary]);
if (state === 'checking') {
return (
<Sheet open={open} onClose={() => setOpen(false)}>
<LoadingState text="دارم پرداخت رو بررسی می‌کنم…" />
</Sheet>
);
}
const paid = state === 'paid';
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 ? 'پرداخت موفق بود! 🎉' : state === 'unknown' ? 'وضعیت پرداخت معلوم نشد' : 'پرداخت انجام نشد'}
</h2>
<p className="text-sm leading-7 text-muted">
{paid
? 'اشتراکت فعال شد؛ از الان قلب‌هات نامحدوده.'
: state === 'unknown'
? 'چند دقیقه دیگه از «پرداخت‌ها و فاکتورها» در پروفایل نگاه کن. اگه مبلغی کم شده، یا خریدت فعال می‌شه یا پول خودکار برمی‌گرده.'
: 'اگه مبلغی از حسابت کم شده، نگران نباش؛ طی چند ساعت خودکار برمی‌گرده یا اشتراکت فعال می‌شه.'}
</p>
<PrimaryButton color={paid ? '#5FBF8F' : '#ED6956'} onClick={() => setOpen(false)}>باشه</PrimaryButton>
</div>
</Sheet>
);
}

View File

@@ -0,0 +1,27 @@
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

@@ -0,0 +1,66 @@
"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

@@ -0,0 +1,157 @@
"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

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

@@ -0,0 +1,11 @@
"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

@@ -0,0 +1,53 @@
"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

@@ -0,0 +1,46 @@
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

@@ -0,0 +1,109 @@
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

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,75 @@
"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

@@ -0,0 +1,92 @@
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

@@ -0,0 +1,241 @@
"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

@@ -0,0 +1,353 @@
"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

@@ -0,0 +1,32 @@
"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

@@ -0,0 +1,33 @@
"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

@@ -0,0 +1,177 @@
"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

@@ -0,0 +1,252 @@
"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

@@ -0,0 +1,135 @@
"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

@@ -0,0 +1,132 @@
"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

@@ -0,0 +1,257 @@
"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

@@ -0,0 +1,168 @@
"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

@@ -0,0 +1,44 @@
"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

@@ -0,0 +1,77 @@
"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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,24 @@
"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

@@ -0,0 +1,276 @@
"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

@@ -0,0 +1,168 @@
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

@@ -0,0 +1,127 @@
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

@@ -0,0 +1,48 @@
"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

@@ -0,0 +1,31 @@
"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

@@ -0,0 +1,45 @@
"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

@@ -0,0 +1,56 @@
"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

@@ -0,0 +1,58 @@
"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

@@ -0,0 +1,189 @@
"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

@@ -0,0 +1,28 @@
"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

@@ -0,0 +1,139 @@
"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

@@ -0,0 +1,726 @@
"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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,63 @@
"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

@@ -0,0 +1,25 @@
"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

@@ -0,0 +1,31 @@
"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

@@ -0,0 +1,116 @@
"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

@@ -0,0 +1,66 @@
"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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,73 @@
"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

@@ -0,0 +1,47 @@
"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

@@ -0,0 +1,61 @@
"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

@@ -0,0 +1,21 @@
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

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

View File

@@ -1,30 +0,0 @@
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);
}
// فقط شماره‌ی سفارش: هر چیزِ دیگری (مثلاً «../../user/delete») از یک لینکِ ساختگی آمده است.
return orderId && /^\d{1,12}$/.test(orderId) ? { orderId, status: status ?? '' } : null;
}
/** یک‌بار، هنگامِ بار شدنِ اپ. */
export const launchPayment = readLaunchParams();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

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