first commit

This commit is contained in:
Ahmadreza Badiei 2025-11-18 20:22:25 +03:30
commit 948238b6d9
55 changed files with 13772 additions and 0 deletions

87
.cursorrules Normal file
View File

@ -0,0 +1,87 @@
# Cursor Rules for PWA React App
## Component Development Guidelines
### Structure
- Use functional components with TypeScript
- Follow the folder structure: components should be in `src/components/`
- Use PascalCase for component names
- Create separate files for each component
### Code Style
- Use TypeScript for type safety
- Use functional components with hooks
- Prefer named exports for components
- Use arrow functions for component definitions
- Keep components small and focused (single responsibility)
### Styling
- Use Tailwind CSS classes for styling
- Use `cn()` utility from `@/lib/utils` for conditional classes
- Prefer shadcn/ui components when available
- Use CSS variables for theming (defined in globals.css)
- Support RTL direction (Persian)
### State Management
- Use Jotai atoms for global state (in `src/atoms/`)
- Use React hooks (useState, useEffect) for local component state
- Custom hooks should be in `src/hooks/`
### API & Services
- All Supabase calls should go through `src/services/supabase.ts`
- Create custom hooks for data fetching (e.g., `useRequests`, `useChat`)
- Handle loading and error states
### Routing
- Use React Router v6+
- Use lazy loading for route components
- Wrap protected routes with `<ProtectedRoute>` component
- Use `useNavigate` for programmatic navigation
### Forms
- Use controlled components with useState
- Validate inputs before submission
- Show error messages with Toast notifications
- Disable submit button during loading
### Accessibility
- Use semantic HTML elements
- Add proper labels for form inputs
- Ensure keyboard navigation works
- Use ARIA attributes when needed
### Performance
- Use React.memo for expensive components when needed
- Implement lazy loading for routes
- Optimize images and assets
- Use Suspense boundaries
### TypeScript
- Define types for all props
- Use interfaces for object types
- Avoid `any` type, use `unknown` if needed
- Export types from service files
### File Organization
- Keep related files together
- Use index files for clean imports when appropriate
- Separate concerns (UI, logic, data)
### Best Practices
- Always handle async operations with try/catch
- Show loading states during async operations
- Provide user feedback with Toast notifications
- Clean up subscriptions and effects properly
- Use meaningful variable and function names in Persian context

8
.env.example Normal file
View File

@ -0,0 +1,8 @@
# Supabase Configuration
# دریافت این مقادیر از https://supabase.com/dashboard
# URL پروژه Supabase شما
VITE_SUPABASE_URL=https://your-project-id.supabase.co
# کلید عمومی (Anonymous) Supabase
VITE_SUPABASE_ANON_KEY=your-anon-key-here

30
.eslintrc.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended'
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true }
],
'@typescript-eslint/no-explicit-any': 'warn'
}
};

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# PWA files
sw.js
workbox-*.js

21
.prettierrc Normal file
View File

@ -0,0 +1,21 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}

60
CHANGELOG.md Normal file
View File

@ -0,0 +1,60 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2024-01-XX
### Added
- Initial release
- React 18 with Vite setup
- TypeScript configuration
- Tailwind CSS integration
- shadcn/ui components (Button, Card, Input, Label, Toast, Avatar)
- React Router v6 with protected routes
- Jotai for global state management
- Supabase integration (Auth, Database, Realtime)
- PWA configuration (manifest, service worker)
- RTL support for Persian language
- IRANSans font globally
- Main page (Dashboard)
- Account page with user profile
- Request page (Create/view requests)
- Chat page with real-time messaging
- Login page (Email, Phone OTP, Google)
- Signup page
- ESLint and Prettier configuration
- Code splitting with lazy loading
- Dark mode ready styles
### Features
- Google OAuth authentication
- Phone number authentication with OTP
- Email/Password authentication
- Protected routes
- Real-time updates for requests and messages
- Responsive design
- Toast notifications
- Loading states
- Error handling
### Documentation
- README.md with setup instructions
- CHANGELOG.md
- .gitignore
- CursorRule file for component development
[1.0.0]: https://github.com/yourusername/pwa-react-app/releases/tag/v1.0.0

62
FONT_DEBUG_GUIDE.md Normal file
View File

@ -0,0 +1,62 @@
# راهنمای بررسی مشکل فونت Bold در Developer Tools
## مرحله 1: باز کردن Developer Tools
1. در مرورگر، کلید `F12` را بزنید (یا `Ctrl+Shift+I` در Windows/Linux، `Cmd+Option+I` در Mac)
2. Developer Tools باز می‌شود
## مرحله 2: بررسی Network Tab (بررسی لود شدن فونت‌ها)
1. به تب **Network** بروید
2. در فیلتر بالا، `font` را تایپ کنید تا فقط فایل‌های فونت نمایش داده شوند
3. صفحه را **Refresh** کنید (F5 یا Ctrl+R)
4. بررسی کنید:
- آیا `IRANSans-Bold.ttf` در لیست وجود دارد؟
- آیا Status آن `200` (موفق) است یا خطا دارد؟
- اگر خطا دارد، روی آن کلیک کنید و ببینید چه خطایی است
## مرحله 3: بررسی Elements Tab (بررسی استفاده از فونت)
1. به تب **Elements** (یا **Inspector** در Firefox) بروید
2. روی یک متن **Bold** کلیک راست کنید و **Inspect** را انتخاب کنید
3. در پنل سمت راست، به بخش **Computed** بروید
4. بررسی کنید:
- `font-family`: باید `IRANSans` باشد
- `font-weight`: باید `700` باشد
- `font-synthesis`: باید `none` باشد
## مرحله 4: بررسی Fonts Tab (در Chrome)
1. در تب Elements، یک عنصر با فونت Bold را انتخاب کنید
2. در پنل سمت راست، به بخش **Fonts** بروید
3. بررسی کنید:
- آیا فونت `IRANSans Bold` نمایش داده می‌شود؟
- یا `IRANSans (synthetic bold)` نمایش داده می‌شود؟
اگر `synthetic bold` نمایش داده می‌شود، یعنی فایل فونت Bold لود نشده است.
## مرحله 5: بررسی Console برای خطاها
1. به تب **Console** بروید
2. بررسی کنید آیا خطایی مربوط به فونت وجود دارد
3. معمولاً خطاهایی مثل:
- `Failed to load font`
- `404 Not Found`
- `CORS error`
## راه‌حل‌های احتمالی:
### اگر فونت Bold لود نمی‌شود:
- بررسی کنید فایل `IRANSans-Bold.ttf` در پوشه `public/fonts/` وجود دارد
- نام فایل باید دقیقاً `IRANSans-Bold.ttf` باشد (حساس به حروف بزرگ/کوچک)
### اگر فونت Bold لود می‌شود اما بهم‌ریخته است:
- ممکن است مشکل از خود فایل فونت باشد
- یا نیاز به تنظیمات اضافی CSS باشد

107
QUICKSTART.md Normal file
View File

@ -0,0 +1,107 @@
# راهنمای سریع راه‌اندازی
## نصب سریع (5 دقیقه)
### 1. نصب پکیج‌ها
```bash
npm install
```
### 2. تنظیم Supabase
یک فایل `.env` در ریشه پروژه بسازید:
```env
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key
```
### 3. ساخت جداول Database
در Supabase SQL Editor اجرا کنید:
```sql
-- جدول requests
create table requests (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
description text not null,
status text default 'pending' check (status in ('pending', 'in_progress', 'completed')),
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table requests enable row level security;
create policy "Anyone can view requests" on requests for select using (true);
create policy "Users can insert their own requests" on requests for insert with check (auth.uid() = user_id);
create policy "Users can update their own requests" on requests for update using (auth.uid() = user_id);
-- جدول messages
create table messages (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
content text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table messages enable row level security;
create policy "Anyone can view messages" on messages for select using (true);
create policy "Authenticated users can insert messages" on messages for insert with check (auth.uid() = user_id);
```
### 4. فعال‌سازی Realtime
در Supabase Dashboard:
- Database > Replication
- فعال کردن Realtime برای `requests` و `messages`
### 5. اجرا
```bash
npm run dev
```
باز کنید: http://localhost:5173
## ساخت برای Production
```bash
npm run build
npm run preview
```
## نکات مهم
- ✅ فونت IRANSans: فایل‌های فونت را در `public/fonts/` قرار دهید
- ✅ آیکون‌های PWA: آیکون‌های 192x192 و 512x512 را در `public/` قرار دهید
- ✅ Google OAuth: در Supabase Dashboard فعال کنید (اختیاری)
## ساختار پروژه
```
src/
├── pages/ # صفحات اصلی
├── components/ # کامپوننت‌ها
├── hooks/ # Custom hooks
├── atoms/ # Jotai state
├── services/ # Supabase client
└── utils/ # توابع کمکی
```
---
برای اطلاعات بیشتر به [README.md](./README.md) مراجعه کنید.

209
README.md Normal file
View File

@ -0,0 +1,209 @@
# PWA React App
یک اپلیکیشن Progressive Web App (PWA) کامل و آماده تولید با React، TypeScript، و Supabase.
## 🚀 ویژگی‌ها
- ✅ **PWA** - قابل نصب و استفاده آفلاین
- ✅ **React 18** با Vite
- ✅ **TypeScript** برای نوع‌بندی کامل
- ✅ **Tailwind CSS** برای استایل‌دهی
- ✅ **shadcn/ui** برای کامپوننت‌های UI
- ✅ **React Router** برای مسیریابی
- ✅ **Jotai** برای مدیریت state سراسری
- ✅ **Supabase** برای Backend، Auth و Database
- ✅ **RTL Support** برای زبان فارسی
- ✅ **IRANSans Font** به صورت پیش‌فرض
- ✅ **Dark Mode Ready**
## 📦 نصب
```bash
# نصب وابستگی‌ها
npm install
# یا با yarn
yarn install
# یا با pnpm
pnpm install
```
## ⚙️ تنظیمات
### 1. تنظیمات Supabase
یک فایل `.env` در ریشه پروژه ایجاد کنید:
```env
VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
```
### 2. ساختار Database در Supabase
پس از راه‌اندازی Supabase، جداول زیر را ایجاد کنید:
#### جدول `requests`:
```sql
create table requests (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
description text not null,
status text default 'pending' check (status in ('pending', 'in_progress', 'completed')),
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- Enable RLS
alter table requests enable row level security;
-- Policy: Users can read all requests
create policy "Anyone can view requests" on requests
for select using (true);
-- Policy: Users can insert their own requests
create policy "Users can insert their own requests" on requests
for insert with check (auth.uid() = user_id);
-- Policy: Users can update their own requests
create policy "Users can update their own requests" on requests
for update using (auth.uid() = user_id);
```
#### جدول `messages`:
```sql
create table messages (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
content text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- Enable RLS
alter table messages enable row level security;
-- Policy: Anyone can view messages
create policy "Anyone can view messages" on messages
for select using (true);
-- Policy: Authenticated users can insert messages
create policy "Authenticated users can insert messages" on messages
for insert with check (auth.uid() = user_id);
```
#### فعال‌سازی Realtime:
در Supabase Dashboard:
1. به قسمت Database > Replication بروید
2. Realtime را برای جداول `requests` و `messages` فعال کنید
## 🏃 اجرا
```bash
# اجرای محیط توسعه
npm run dev
# ساخت نسخه production
npm run build
# پیش‌نمایش نسخه production
npm run preview
```
## 📁 ساختار پروژه
```
src/
├── assets/ # فایل‌های استاتیک
├── components/ # کامپوننت‌های React
│ └── ui/ # کامپوننت‌های shadcn/ui
├── atoms/ # Jotai atoms برای state management
├── hooks/ # Custom hooks
├── utils/ # توابع کمکی
├── services/ # سرویس‌های خارجی (Supabase)
├── pages/ # صفحات اصلی
│ ├── Main/
│ ├── Account/
│ ├── Request/
│ ├── Chat/
│ └── Auth/
│ ├── Login/
│ └── Signup/
├── styles/ # فایل‌های CSS
├── App.tsx # کامپوننت اصلی
└── main.tsx # نقطه ورود
```
## 🔐 احراز هویت
این اپلیکیشن از Supabase Auth پشتیبانی می‌کند:
- **ورود با ایمیل/رمز عبور**
- **ثبت نام با ایمیل**
- **ورود با Google OAuth**
- **ورود با شماره تلفن (OTP)**
## 🎨 کامپوننت‌های UI
از shadcn/ui استفاده شده است:
- Button
- Card
- Input
- Label
- Toast
- Avatar
## 📱 PWA
- Manifest برای نصب
- Service Worker برای عملکرد آفلاین
- Splash Screen
- آیکون‌های مختلف اندازه
## 🌐 پشتیبانی RTL
- جهت RTL به صورت پیش‌فرض
- فونت IRANSans
- زبان فارسی
## 📝 Scripts
- `npm run dev` - اجرای محیط توسعه
- `npm run build` - ساخت نسخه production
- `npm run preview` - پیش‌نمایش نسخه production
- `npm run lint` - بررسی کد با ESLint
- `npm run format` - فرمت کد با Prettier
## 🛠️ تکنولوژی‌ها
- **React 18.2**
- **TypeScript 5.2**
- **Vite 5.0**
- **React Router 6.21**
- **Jotai 2.6**
- **Supabase 2.39**
- **Tailwind CSS 3.4**
- **shadcn/ui**
## 📄 لایسنس
MIT
## 🤝 مشارکت
مشارکت‌ها خوش‌آمدند! لطفا ابتدا یک Issue ایجاد کنید.
---
ساخته شده با ❤️ توسط React و Vite

240
SUPABASE_SETUP.md Normal file
View File

@ -0,0 +1,240 @@
# راهنمای کامل اتصال پروژه به Supabase
این راهنما به شما کمک می‌کند که پروژه را به Supabase متصل کنید.
## 📋 پیش‌نیازها
- یک حساب ایمیل (برای ثبت‌نام در Supabase)
- VPN (در صورت فیلتر بودن Supabase در ایران)
---
## 🚀 مرحله 1: ساخت حساب Supabase
1. به آدرس [https://supabase.com](https://supabase.com) بروید
2. روی دکمه **"Start your project"** یا **"Sign Up"** کلیک کنید
3. با یکی از روش‌های زیر ثبت‌نام کنید:
- GitHub
- Google
- ایمیل
4. پس از ثبت‌نام و تأیید ایمیل، وارد Dashboard شوید
---
## 🆕 مرحله 2: ایجاد پروژه جدید
1. در Dashboard، روی دکمه **"New Project"** کلیک کنید
2. اطلاعات زیر را وارد کنید:
- **Name**: نام پروژه شما (مثلاً: `pwa-react-app`)
- **Database Password**: یک رمز عبور قوی انتخاب کنید (حتماً ذخیره کنید!)
- **Region**: نزدیک‌ترین منطقه را انتخاب کنید (مثلاً `West US` یا `Southeast Asia`)
- **Pricing Plan**: Free plan را انتخاب کنید
3. روی **"Create new project"** کلیک کنید
4. **صبر کنید** تا پروژه ایجاد شود (این کار 2-3 دقیقه طول می‌کشد)
---
## 🔑 مرحله 3: دریافت API Keys
1. پس از ایجاد پروژه، به صفحه **Settings** بروید
2. در منوی سمت چپ، روی **"API"** کلیک کنید
3. در این صفحه دو مقدار مهم را پیدا می‌کنید:
- **Project URL**: آدرس پروژه شما
```
https://xxxxxxxxxxxxx.supabase.co
```
- **anon public key**: کلید عمومی (این کلید را می‌توانید در frontend استفاده کنید)
```
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
4. این دو مقدار را کپی کنید (بعداً نیاز دارید)
---
## 📝 مرحله 4: ایجاد فایل `.env`
1. در ریشه پروژه (همان فولدری که `package.json` است)، یک فایل جدید با نام `.env` ایجاد کنید
2. محتوای زیر را در آن قرار دهید:
```env
VITE_SUPABASE_URL=https://xxxxxxxxxxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**⚠️ توجه**: مقادیر را با اطلاعات واقعی پروژه خود جایگزین کنید!
3. فایل را ذخیره کنید
---
## 🗄️ مرحله 5: ایجاد جداول در Database
1. در Supabase Dashboard، به بخش **SQL Editor** بروید (از منوی سمت چپ)
2. روی دکمه **"New query"** کلیک کنید
3. کد SQL زیر را کپی و در ویرایشگر قرار دهید:
```sql
-- ایجاد جدول requests
create table requests (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
description text not null,
status text default 'pending' check (status in ('pending', 'in_progress', 'completed')),
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- فعال‌سازی Row Level Security
alter table requests enable row level security;
-- Policy: همه می‌توانند درخواست‌ها را ببینند
create policy "Anyone can view requests" on requests
for select using (true);
-- Policy: کاربران می‌توانند درخواست خودشان را ایجاد کنند
create policy "Users can insert their own requests" on requests
for insert with check (auth.uid() = user_id);
-- Policy: کاربران می‌توانند درخواست خودشان را به‌روز کنند
create policy "Users can update their own requests" on requests
for update using (auth.uid() = user_id);
-- ایجاد جدول messages
create table messages (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
content text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- فعال‌سازی Row Level Security
alter table messages enable row level security;
-- Policy: همه می‌توانند پیام‌ها را ببینند
create policy "Anyone can view messages" on messages
for select using (true);
-- Policy: کاربران احراز هویت شده می‌توانند پیام ارسال کنند
create policy "Authenticated users can insert messages" on messages
for insert with check (auth.uid() = user_id);
```
4. روی دکمه **"Run"** یا **Ctrl+Enter** بزنید
5. اگر همه چیز درست بود، پیام موفقیت را می‌بینید
---
## 🔄 مرحله 6: فعال‌سازی Realtime
برای اینکه تغییرات به صورت real-time نمایش داده شوند:
1. در Dashboard، به بخش **Database** بروید
2. روی **"Replication"** از منوی سمت چپ کلیک کنید
3. برای هر دو جدول (`requests` و `messages`):
- کلید toggle را **فعال** کنید
- یا از منوی dropdown، **"Enable"** را انتخاب کنید
---
## 🔐 مرحله 7: تنظیمات Authentication (اختیاری)
### تنظیم Email Authentication:
به صورت پیش‌فرض فعال است و نیاز به تنظیم اضافی ندارد.
### تنظیم Google OAuth (اختیاری):
اگر می‌خواهید ورود با Google داشته باشید:
1. به [Google Cloud Console](https://console.cloud.google.com) بروید
2. یک پروژه جدید ایجاد کنید یا پروژه موجود را انتخاب کنید
3. به **APIs & Services > Credentials** بروید
4. روی **"Create Credentials > OAuth client ID"** کلیک کنید
5. نوع را **Web application** انتخاب کنید
6. **Authorized redirect URIs** را اضافه کنید:
```
https://xxxxxxxxxxxxx.supabase.co/auth/v1/callback
```
(URL پروژه Supabase خود را جایگزین کنید)
7. **Client ID** و **Client Secret** را کپی کنید
8. در Supabase Dashboard:
- به **Authentication > Providers** بروید
- **Google** را پیدا کنید و فعال کنید
- Client ID و Client Secret را وارد کنید
- ذخیره کنید
---
## ✅ مرحله 8: تست اتصال
1. سرور توسعه را متوقف کنید (اگر در حال اجراست)
2. دوباره اجرا کنید:
```bash
npm run dev
```
3. به آدرس `http://localhost:5173` یا `http://localhost:5174` بروید
4. باید بنر زرد (حالت offline) **ناپدید** شود
5. صفحه Login را ببینید
6. یک حساب کاربری جدید بسازید و تست کنید
---
## 🐛 رفع مشکلات
### مشکل: صفحه سفید یا خطا در console
**راه‌حل:**
- مطمئن شوید فایل `.env` در ریشه پروژه است
- مقادیر URL و KEY را دوباره بررسی کنید
- سرور را restart کنید
### مشکل: خطای "Invalid API key"
**راه‌حل:**
- کلید `anon key` را دوباره از Settings > API کپی کنید
- مطمئن شوید کلید کامل است و فضای خالی اضافی ندارد
### مشکل: خطای Database connection
**راه‌حل:**
- مطمئن شوید جداول را ایجاد کرده‌اید (مرحله 5)
- بررسی کنید که Row Level Security policies درست تنظیم شده‌اند
### مشکل: Realtime کار نمی‌کند
**راه‌حل:**
- مطمئن شوید Realtime را برای جداول فعال کرده‌اید (مرحله 6)
---
## 📚 منابع بیشتر
- [مستندات Supabase](https://supabase.com/docs)
- [راهنمای Authentication](https://supabase.com/docs/guides/auth)
- [راهنمای Database](https://supabase.com/docs/guides/database)
---
## 🎉 تبریک!
اگر همه مراحل را با موفقیت انجام دادید، پروژه شما باید به Supabase متصل شده باشد و تمام ویژگی‌ها کار کنند.
**خلاصه کارهایی که انجام دادید:**
- ✅ حساب Supabase ساختید
- ✅ پروژه جدید ایجاد کردید
- ✅ API Keys را دریافت کردید
- ✅ فایل `.env` را ساختید
- ✅ جداول Database را ایجاد کردید
- ✅ Realtime را فعال کردید
اگر سوالی دارید یا مشکلی پیش آمد، می‌توانید از مستندات Supabase یا تیم پشتیبانی کمک بگیرید.

21
index.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#ffffff" />
<meta name="description" content="Production-ready Progressive Web App" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>PWA React App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

9664
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"name": "pwa-react-app",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev:host": "vite --host",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,json,css}\""
},
"dependencies": {
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@supabase/supabase-js": "^2.39.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"jotai": "^2.6.0",
"lucide-react": "^0.309.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.1",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"prettier": "^3.1.1",
"tailwindcss": "^3.4.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vite-plugin-pwa": "^0.17.4",
"workbox-window": "^7.0.0"
}
}

17
postcss.config.js Normal file
View File

@ -0,0 +1,17 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View File

@ -0,0 +1,27 @@
# PWA Icons
این فولدر باید شامل آیکون‌های زیر برای PWA باشد:
- `pwa-192x192.png` - آیکون 192x192 پیکسل
- `pwa-512x512.png` - آیکون 512x512 پیکسل
- `apple-touch-icon.png` - آیکون اپل (180x180 پیکسل)
- `mask-icon.svg` - آیکون SVG برای Safari
برای ایجاد این آیکون‌ها می‌توانید از ابزارهای آنلاین استفاده کنید:
- https://realfavicongenerator.net/
- https://www.pwabuilder.com/imageGenerator
یا می‌توانید یک لوگوی 512x512 بسازید و آن را به اندازه‌های مختلف تغییر اندازه دهید.
توجه: این فایل‌ها باید در فولدر `public/` قرار گیرند.

View File

@ -0,0 +1,87 @@
# راهنمای اضافه کردن فونت سفارشی
## مراحل اضافه کردن فونت:
### 1. قرار دادن فایل‌های فونت
فایل‌های فونت خود را در پوشه `public/fonts/` قرار دهید.
### 2. نام‌گذاری فایل‌ها
فایل‌های فونت را با این نام‌ها ذخیره کنید:
- `IRANSans-Regular.woff2` - برای وزن معمولی (font-weight: normal)
- `IRANSans-Bold.woff2` - برای وزن Bold (font-weight: bold)
- `IRANSans-Medium.woff2` - برای وزن Medium (font-weight: 500)
- `IRANSans-Light.woff2` - برای وزن Light (font-weight: 300)
**نکته:** اگر نام فایل‌های شما متفاوت است، باید در `src/styles/globals.css` مسیرها را تغییر دهید.
### 3. فرمت‌های پشتیبانی شده
- `.woff2` (پیشنهادی - بهترین کیفیت)
- `.woff`
- `.ttf`
- `.otf`
### 4. تنظیمات در globals.css
اگر نام فونت یا مسیر فایل‌ها متفاوت است، فایل `src/styles/globals.css` را ویرایش کنید:
```css
@font-face {
font-family: 'نام-فونت-شما';
src: url('/fonts/نام-فایل-فونت.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
```
### 5. تنظیمات در tailwind.config.js
اگر نام فونت را تغییر دادید، در `tailwind.config.js` هم به‌روز کنید:
```js
fontFamily: {
sans: ['نام-فونت-شما', 'Tahoma', 'Arial', 'system-ui', 'sans-serif']
}
```
## مثال برای فونت‌های دیگر:
اگر فونت دیگری دارید (مثلاً Vazir):
1. فایل‌ها را در `public/fonts/` قرار دهید:
- `Vazir-Regular.woff2`
- `Vazir-Bold.woff2`
2. در `globals.css` اضافه کنید:
```css
@font-face {
font-family: 'Vazir';
src: url('/fonts/Vazir-Regular.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Vazir';
src: url('/fonts/Vazir-Bold.woff2') format('woff2');
font-weight: bold;
font-style: normal;
font-display: swap;
}
```
3. در `tailwind.config.js`:
```js
fontFamily: {
sans: ['Vazir', 'Tahoma', 'Arial', 'system-ui', 'sans-serif']
}
```
## تست فونت:
بعد از اضافه کردن فونت، صفحه را refresh کنید و در Developer Tools (F12) بررسی کنید که فونت به درستی لود شده است.

Binary file not shown.

28
public/fonts/README.md Normal file
View File

@ -0,0 +1,28 @@
# فونت‌های سفارشی
فایل‌های فونت خود را در این پوشه قرار دهید.
## فرمت‌های پشتیبانی شده:
- `.woff2` (پیشنهادی - بهترین کیفیت و کوچک‌ترین حجم)
- `.woff`
- `.ttf`
- `.otf`
## نام‌گذاری فایل‌ها:
برای فونت ایران سنس به عنوان مثال:
- `IRANSans-Regular.woff2` - وزن معمولی
- `IRANSans-Bold.woff2` - وزن Bold
- `IRANSans-Medium.woff2` - وزن Medium
- `IRANSans-Light.woff2` - وزن Light
## تنظیمات:
بعد از قرار دادن فایل‌های فونت، باید در `src/styles/globals.css` تنظیمات `@font-face` را به‌روز کنید.

View File

@ -0,0 +1,38 @@
{
"name": "PWA React App",
"short_name": "PWA App",
"description": "Production-ready Progressive Web App",
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone",
"orientation": "portrait",
"scope": "/",
"start_url": "/",
"dir": "rtl",
"lang": "fa",
"icons": [
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

12
public/vite.svg Normal file
View File

@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

92
src/App.tsx Normal file
View File

@ -0,0 +1,92 @@
import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from '@/components/ui/toaster';
import { ProtectedRoute } from '@/components/ProtectedRoute';
import { Navbar } from '@/components/Navbar';
import { isSupabaseConfigured } from '@/services/supabase';
import { WifiOff } from 'lucide-react';
import { useTheme } from '@/hooks/useTheme';
// Lazy load pages for code splitting
const MainPage = lazy(() => import('@/pages/Main/MainPage'));
const AccountPage = lazy(() => import('@/pages/Account/AccountPage'));
const RequestPage = lazy(() => import('@/pages/Request/RequestPage'));
const ChatPage = lazy(() => import('@/pages/Chat/ChatPage'));
const LoginPage = lazy(() => import('@/pages/Auth/Login/LoginPage'));
const SignupPage = lazy(() => import('@/pages/Auth/Signup/SignupPage'));
// Loading component
const PageLoader = () => (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
</div>
);
function App() {
// Initialize theme
useTheme();
return (
<BrowserRouter>
<div className="min-h-screen bg-background">
{/* Offline Mode Banner */}
{!isSupabaseConfigured && (
<div className="bg-yellow-500/10 border-b border-yellow-500/20 px-4 py-2">
<div className="container mx-auto flex items-center gap-2 text-sm text-yellow-600 dark:text-yellow-400">
<WifiOff className="h-4 w-4" />
<span>حالت آفلاین: Supabase پیکربندی نشده است. برای استفاده کامل از ویژگیها، فایل .env را تنظیم کنید.</span>
</div>
</div>
)}
<Navbar />
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
{/* Protected routes */}
<Route
path="/"
element={
<ProtectedRoute>
<MainPage />
</ProtectedRoute>
}
/>
<Route
path="/account"
element={
<ProtectedRoute>
<AccountPage />
</ProtectedRoute>
}
/>
<Route
path="/request"
element={
<ProtectedRoute>
<RequestPage />
</ProtectedRoute>
}
/>
<Route
path="/chat"
element={
<ProtectedRoute>
<ChatPage />
</ProtectedRoute>
}
/>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
<Toaster />
</div>
</BrowserRouter>
);
}
export default App;

24
src/atoms/auth.ts Normal file
View File

@ -0,0 +1,24 @@
import { atom } from 'jotai';
import type { User, Session } from '@supabase/supabase-js';
export const sessionAtom = atom<Session | null>(null);
export const userAtom = atom<User | null>((get) => {
const session = get(sessionAtom);
return session?.user ?? null;
});
export const isAuthenticatedAtom = atom<boolean>((get) => {
const session = get(sessionAtom);
return !!session?.user;
});

11
src/atoms/theme.ts Normal file
View File

@ -0,0 +1,11 @@
import { atom } from 'jotai';
export type Theme = 'light' | 'dark';
// Atom برای مدیریت theme
export const themeAtom = atom<Theme>('light');

116
src/components/Navbar.tsx Normal file
View File

@ -0,0 +1,116 @@
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { Home, User, MessageSquare, FileText, LogOut, Moon, Sun } from 'lucide-react';
import { useState } from 'react';
export function Navbar() {
const { isAuthenticated, signOut } = useAuth();
const { toggleTheme, isDark } = useTheme();
const navigate = useNavigate();
const location = useLocation();
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const handleSignOut = async () => {
await signOut();
navigate('/');
setShowLogoutConfirm(false);
};
if (!isAuthenticated) {
return null;
}
const navLinks = [
{ to: '/', icon: Home, label: 'خانه' },
{ to: '/request', icon: FileText, label: 'درخواست‌ها' },
{ to: '/chat', icon: MessageSquare, label: 'چت' },
{ to: '/account', icon: User, label: 'حساب' }
];
const isActive = (path: string) => {
return location.pathname === path;
};
return (
<>
{/* Bottom Navigation Bar */}
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 safe-area-inset-bottom">
<div className="mx-auto max-w-sm">
<div className="flex items-center justify-around h-16 px-2">
{navLinks.map((link) => {
const Icon = link.icon;
const active = isActive(link.to);
return (
<Link
key={link.to}
to={link.to}
className={`relative flex flex-col items-center justify-center gap-1 flex-1 h-full transition-colors ${
active
? 'text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Icon className={`h-5 w-5 ${active ? 'scale-110' : ''} transition-transform`} />
<span className="text-xs font-medium">{link.label}</span>
{active && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-8 h-0.5 bg-primary rounded-t-full" />
)}
</Link>
);
})}
<button
onClick={toggleTheme}
className="flex flex-col items-center justify-center gap-1 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors"
aria-label={isDark ? 'تغییر به حالت روشن' : 'تغییر به حالت تاریک'}
>
{isDark ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
<span className="text-xs font-medium">{isDark ? 'روشن' : 'تاریک'}</span>
</button>
<button
onClick={() => setShowLogoutConfirm(true)}
className="flex flex-col items-center justify-center gap-1 flex-1 h-full text-muted-foreground hover:text-destructive transition-colors"
>
<LogOut className="h-5 w-5" />
<span className="text-xs font-medium">خروج</span>
</button>
</div>
</div>
</nav>
{/* Logout Confirmation Modal */}
{showLogoutConfirm && (
<div className="fixed inset-0 z-[60] bg-black/50 flex items-end justify-center p-4">
<div className="bg-background rounded-t-2xl w-full max-w-sm p-6 space-y-4 shadow-lg">
<h3 className="text-lg font-semibold">خروج از حساب کاربری</h3>
<p className="text-sm text-muted-foreground">
آیا مطمئن هستید که میخواهید خارج شوید؟
</p>
<div className="flex gap-3">
<button
onClick={() => setShowLogoutConfirm(false)}
className="flex-1 px-4 py-2 rounded-lg border border-input bg-background hover:bg-accent text-sm font-medium transition-colors"
>
انصراف
</button>
<button
onClick={handleSignOut}
className="flex-1 px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 text-sm font-medium transition-colors"
>
خروج
</button>
</div>
</div>
</div>
)}
</>
);
}

View File

@ -0,0 +1,25 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAtom } from 'jotai';
import { isAuthenticatedAtom } from '@/atoms/auth';
import { isSupabaseConfigured } from '@/services/supabase';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const [isAuthenticated] = useAtom(isAuthenticatedAtom);
const location = useLocation();
// In offline mode, allow access without authentication
if (!isSupabaseConfigured) {
return <>{children}</>;
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}

View File

@ -0,0 +1,55 @@
import * as React from 'react';
import * as AvatarPrimitive from '@radix-ui/react-avatar';
import { cn } from '@/lib/utils';
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn('aspect-square h-full w-full', className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };

View File

@ -0,0 +1,66 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
{...props}
/>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@ -0,0 +1,34 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = 'Input';
export { Input };

View File

@ -0,0 +1,29 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

132
src/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,132 @@
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground'
}
},
defaultVariants: {
variant: 'default'
}
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute left-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction
};

View File

@ -0,0 +1,42 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport
} from './toast';
import { useToast } from './use-toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -0,0 +1,185 @@
import * as React from 'react';
import type { ToastActionElement, ToastProps } from './toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 5000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST'
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
)
};
case 'DISMISS_TOAST': {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false
}
: t
)
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: []
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId)
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id }
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
}
}
});
return {
id: id,
dismiss,
update
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId })
};
}
export { useToast, toast };

126
src/docs/SETUP.md Normal file
View File

@ -0,0 +1,126 @@
# راهنمای راه‌اندازی
## پیش‌نیازها
- Node.js 18+ و npm/yarn/pnpm
- حساب Supabase
## مراحل نصب
### 1. نصب وابستگی‌ها
```bash
npm install
```
### 2. تنظیمات محیط
یک فایل `.env` در ریشه پروژه ایجاد کنید:
```env
VITE_SUPABASE_URL=your-supabase-project-url
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
```
مقادیر را از [Supabase Dashboard](https://supabase.com/dashboard) دریافت کنید.
### 3. راه‌اندازی Database
در Supabase SQL Editor، این کدها را اجرا کنید:
```sql
-- ایجاد جدول requests
create table requests (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
title text not null,
description text not null,
status text default 'pending' check (status in ('pending', 'in_progress', 'completed')),
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table requests enable row level security;
create policy "Anyone can view requests" on requests
for select using (true);
create policy "Users can insert their own requests" on requests
for insert with check (auth.uid() = user_id);
create policy "Users can update their own requests" on requests
for update using (auth.uid() = user_id);
-- ایجاد جدول messages
create table messages (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
content text not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table messages enable row level security;
create policy "Anyone can view messages" on messages
for select using (true);
create policy "Authenticated users can insert messages" on messages
for insert with check (auth.uid() = user_id);
```
### 4. فعال‌سازی Realtime
1. به Supabase Dashboard بروید
2. Database > Replication
3. Realtime را برای جداول `requests` و `messages` فعال کنید
### 5. تنظیمات Google OAuth (اختیاری)
1. در Supabase Dashboard به Authentication > Providers بروید
2. Google را فعال کنید
3. Client ID و Client Secret را از Google Cloud Console دریافت و وارد کنید
### 6. اجرای پروژه
```bash
npm run dev
```
پروژه در آدرس `http://localhost:5173` اجرا می‌شود.
## ساخت برای Production
```bash
npm run build
```
فایل‌های خروجی در فولدر `dist/` قرار می‌گیرند.
## اضافه کردن فونت IRANSans
برای استفاده از فونت IRANSans:
1. فایل‌های فونت را از [این آدرس](https://github.com/font-store/IRANSans) دانلود کنید
2. فایل‌های `.woff2` را در فولدر `public/fonts/` قرار دهید
3. نام فایل‌ها باید `IRANSansWeb.woff2` و `IRANSansWeb_Bold.woff2` باشند
## اضافه کردن آیکون‌های PWA
برای کامل کردن PWA:
1. آیکون‌های 192x192 و 512x512 پیکسل ایجاد کنید
2. آن‌ها را در فولدر `public/` با نام‌های `pwa-192x192.png` و `pwa-512x512.png` قرار دهید
3. همچنین `apple-touch-icon.png` (180x180) و `mask-icon.svg` را اضافه کنید
می‌توانید از [RealFaviconGenerator](https://realfavicongenerator.net/) استفاده کنید.

104
src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,104 @@
import { useEffect } from 'react';
import { useAtom } from 'jotai';
import { sessionAtom, userAtom, isAuthenticatedAtom } from '@/atoms/auth';
import { supabase, isSupabaseConfigured } from '@/services/supabase';
export function useAuth() {
const [session, setSession] = useAtom(sessionAtom);
const [user] = useAtom(userAtom);
const [isAuthenticated] = useAtom(isAuthenticatedAtom);
useEffect(() => {
if (!isSupabaseConfigured) {
// In offline mode, set session to null
setSession(null);
return;
}
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
}).catch((error) => {
console.error('Error getting session:', error);
setSession(null);
});
// Listen for auth changes
const {
data: { subscription }
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session);
});
return () => {
subscription.unsubscribe();
};
}, [setSession]);
const signInWithGoogle = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/account`
}
});
if (error) throw error;
};
const signInWithPhone = async (phone: string) => {
const { error } = await supabase.auth.signInWithOtp({
phone,
options: {
channel: 'sms'
}
});
if (error) throw error;
};
const verifyOTP = async (phone: string, token: string) => {
const { error } = await supabase.auth.verifyOtp({
phone,
token,
type: 'sms'
});
if (error) throw error;
};
const signUp = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/account`
}
});
if (error) throw error;
return { data, error: null };
};
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) throw error;
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
};
return {
session,
user,
isAuthenticated,
signInWithGoogle,
signInWithPhone,
verifyOTP,
signUp,
signIn,
signOut
};
}

89
src/hooks/useChat.ts Normal file
View File

@ -0,0 +1,89 @@
import { useState, useEffect } from 'react';
import { supabase, isSupabaseConfigured, type Message } from '@/services/supabase';
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadMessages();
if (!isSupabaseConfigured) {
return;
}
// Subscribe to real-time messages
const channel = supabase
.channel('messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages'
},
(payload) => {
setMessages((prev) => [payload.new as Message, ...prev]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const loadMessages = async () => {
if (!isSupabaseConfigured) {
setMessages([]);
setLoading(false);
return;
}
try {
const { data, error } = await supabase
.from('messages')
.select('*')
.order('created_at', { ascending: false })
.limit(50);
if (error) throw error;
// For now, user data will be fetched separately in the component
// You can create a profiles table and join it here if needed
setMessages((data || []) as Message[]);
} catch (error) {
console.error('Error loading messages:', error);
setMessages([]);
} finally {
setLoading(false);
}
};
const sendMessage = async (content: string, userId: string) => {
if (!isSupabaseConfigured) {
throw new Error('Supabase is not configured. Cannot send messages in offline mode.');
}
try {
const { data, error } = await supabase
.from('messages')
.insert([
{
user_id: userId,
content
}
])
.select()
.single();
if (error) throw error;
return data;
} catch (error) {
console.error('Error sending message:', error);
throw error;
}
};
return { messages, loading, sendMessage };
}

88
src/hooks/useRequests.ts Normal file
View File

@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
import { supabase, isSupabaseConfigured, type Request } from '@/services/supabase';
export function useRequests() {
const [requests, setRequests] = useState<Request[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadRequests();
if (!isSupabaseConfigured) {
return;
}
// Subscribe to real-time updates
const channel = supabase
.channel('requests_changes')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'requests'
},
() => {
loadRequests();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
const loadRequests = async () => {
if (!isSupabaseConfigured) {
setRequests([]);
setLoading(false);
return;
}
try {
const { data, error } = await supabase
.from('requests')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
setRequests(data || []);
} catch (error) {
console.error('Error loading requests:', error);
setRequests([]);
} finally {
setLoading(false);
}
};
const createRequest = async (title: string, description: string, userId: string) => {
if (!isSupabaseConfigured) {
throw new Error('Supabase is not configured. Cannot create requests in offline mode.');
}
try {
const { data, error } = await supabase
.from('requests')
.insert([
{
user_id: userId,
title,
description,
status: 'pending'
}
])
.select()
.single();
if (error) throw error;
return data;
} catch (error) {
console.error('Error creating request:', error);
throw error;
}
};
return { requests, loading, createRequest, loadRequests };
}

43
src/hooks/useTheme.ts Normal file
View File

@ -0,0 +1,43 @@
import { useAtom } from 'jotai';
import { useEffect } from 'react';
import { themeAtom, type Theme } from '@/atoms/theme';
export function useTheme() {
const [theme, setTheme] = useAtom(themeAtom);
useEffect(() => {
// بارگذاری theme از localStorage در اولین بار
const savedTheme = localStorage.getItem('theme') as Theme | null;
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
setTheme(savedTheme);
} else {
// بررسی ترجیح سیستم
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
setTheme(prefersDark ? 'dark' : 'light');
}
}, [setTheme]);
useEffect(() => {
// اعمال theme به document
const root = document.documentElement;
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
// ذخیره در localStorage
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev: Theme) => (prev === 'light' ? 'dark' : 'light'));
};
return {
theme,
setTheme,
toggleTheme,
isDark: theme === 'dark'
};
}

12
src/lib/utils.ts Normal file
View File

@ -0,0 +1,12 @@
export * from '../utils/cn';

21
src/main.tsx Normal file
View File

@ -0,0 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,125 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuth } from '@/hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { LogOut, User as UserIcon, Mail, Phone } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
export default function AccountPage() {
const { user, signOut } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const handleSignOut = async () => {
try {
await signOut();
toast({
title: 'خروج موفق',
description: 'با موفقیت خارج شدید'
});
navigate('/');
} catch (error) {
toast({
title: 'خطا',
description: 'خطا در خروج از حساب کاربری',
variant: 'destructive'
});
}
};
const getUserInitials = () => {
if (user?.email) {
return user.email.substring(0, 2).toUpperCase();
}
if (user?.phone) {
return user.phone.slice(-2);
}
return 'U';
};
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
<div className="mx-auto max-w-sm">
<div className="mb-4">
<h1 className="text-2xl font-bold mb-2">حساب کاربری</h1>
<p className="text-sm text-muted-foreground">مدیریت اطلاعات و تنظیمات حساب کاربری</p>
</div>
<Card>
<CardHeader>
<CardTitle>اطلاعات کاربری</CardTitle>
<CardDescription>جزئیات حساب کاربری شما</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user?.user_metadata?.avatar_url} alt={user?.email || 'User'} />
<AvatarFallback className="text-lg">{getUserInitials()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold mb-2 truncate">
{user?.user_metadata?.full_name || user?.email || user?.phone || 'کاربر'}
</h3>
<div className="space-y-2 text-sm text-muted-foreground">
{user?.email && (
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{user.email}</span>
</div>
)}
{user?.phone && (
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{user.phone}</span>
</div>
)}
</div>
</div>
</div>
<div className="border-t pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="font-medium">شناسه کاربری</div>
<div className="text-sm text-muted-foreground font-mono">
{user?.id?.substring(0, 8)}...
</div>
</div>
<UserIcon className="h-5 w-5 text-muted-foreground" />
</div>
{user?.created_at && (
<div className="flex items-center justify-between">
<div>
<div className="font-medium">تاریخ عضویت</div>
<div className="text-sm text-muted-foreground">
{new Date(user.created_at).toLocaleDateString('fa-IR')}
</div>
</div>
</div>
)}
</div>
</div>
<div className="border-t pt-6">
<Button
variant="destructive"
className="w-full"
onClick={handleSignOut}
>
<LogOut className="ml-2 h-4 w-4" />
خروج از حساب کاربری
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,349 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { useToast } from '@/components/ui/use-toast';
import { Loader2, Mail, Phone, LogIn, Moon, Sun } from 'lucide-react';
export default function LoginPage() {
const navigate = useNavigate();
const { signIn, signInWithGoogle, signInWithPhone, verifyOTP } = useAuth();
const { toggleTheme, isDark } = useTheme();
const { toast } = useToast();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [otpSent, setOtpSent] = useState(false);
const [authMethod, setAuthMethod] = useState<'email' | 'phone' | 'google'>('email');
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast({
title: 'خطا',
description: 'لطفا ایمیل و رمز عبور را وارد کنید',
variant: 'destructive'
});
return;
}
setIsLoading(true);
try {
await signIn(email, password);
toast({
title: 'موفق',
description: 'با موفقیت وارد شدید'
});
navigate('/account');
} catch (error: any) {
let errorMessage = 'خطا در ورود';
if (error.message) {
if (error.message.includes('Invalid login credentials')) {
errorMessage = 'ایمیل یا رمز عبور اشتباه است. لطفا دوباره تلاش کنید یا ثبت نام کنید.';
} else if (error.message.includes('Email not confirmed')) {
errorMessage = 'لطفا ابتدا ایمیل خود را تأیید کنید.';
} else {
errorMessage = error.message;
}
}
toast({
title: 'خطا',
description: errorMessage,
variant: 'destructive'
});
} finally {
setIsLoading(false);
}
};
const handleGoogleLogin = async () => {
setIsLoading(true);
try {
await signInWithGoogle();
// Redirect happens automatically
} catch (error: any) {
toast({
title: 'خطا',
description: error.message || 'خطا در ورود با گوگل',
variant: 'destructive'
});
setIsLoading(false);
}
};
const handleSendOTP = async (e: React.FormEvent) => {
e.preventDefault();
if (!phone) {
toast({
title: 'خطا',
description: 'لطفا شماره تلفن را وارد کنید',
variant: 'destructive'
});
return;
}
setIsLoading(true);
try {
await signInWithPhone(phone);
setOtpSent(true);
toast({
title: 'موفق',
description: 'کد تأیید ارسال شد'
});
} catch (error: any) {
toast({
title: 'خطا',
description: error.message || 'خطا در ارسال کد',
variant: 'destructive'
});
} finally {
setIsLoading(false);
}
};
const handleVerifyOTP = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp) {
toast({
title: 'خطا',
description: 'لطفا کد تأیید را وارد کنید',
variant: 'destructive'
});
return;
}
setIsLoading(true);
try {
await verifyOTP(phone, otp);
toast({
title: 'موفق',
description: 'با موفقیت وارد شدید'
});
navigate('/account');
} catch (error: any) {
toast({
title: 'خطا',
description: error.message || 'کد تأیید نامعتبر است',
variant: 'destructive'
});
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4 relative">
{/* Dark Mode Toggle Button */}
<button
onClick={toggleTheme}
className="absolute top-4 left-4 p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-input hover:bg-accent transition-colors"
aria-label={isDark ? 'تغییر به حالت روشن' : 'تغییر به حالت تاریک'}
>
{isDark ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</button>
<Card className="w-full max-w-sm">
<CardHeader className="space-y-1">
<div className="flex justify-center mb-4">
<LogIn className="h-12 w-12 text-primary" />
</div>
<CardTitle className="text-2xl text-center">ورود</CardTitle>
<CardDescription className="text-center">
به حساب کاربری خود وارد شوید
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Auth Method Selector */}
<div className="flex gap-2">
<Button
type="button"
variant={authMethod === 'email' ? 'default' : 'outline'}
className="flex-1"
onClick={() => {
setAuthMethod('email');
setOtpSent(false);
}}
>
<Mail className="ml-2 h-4 w-4" />
ایمیل
</Button>
<Button
type="button"
variant={authMethod === 'phone' ? 'default' : 'outline'}
className="flex-1"
onClick={() => {
setAuthMethod('phone');
setOtpSent(false);
}}
>
<Phone className="ml-2 h-4 w-4" />
تلفن
</Button>
</div>
{/* Email Login */}
{authMethod === 'email' && (
<form onSubmit={handleEmailLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">ایمیل</Label>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">رمز عبور</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ورود...
</>
) : (
'ورود'
)}
</Button>
</form>
)}
{/* Phone OTP Login */}
{authMethod === 'phone' && (
<>
{!otpSent ? (
<form onSubmit={handleSendOTP} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="phone">شماره تلفن</Label>
<Input
id="phone"
type="tel"
placeholder="09123456789"
value={phone}
onChange={(e) => setPhone(e.target.value)}
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ارسال...
</>
) : (
'ارسال کد تأیید'
)}
</Button>
</form>
) : (
<form onSubmit={handleVerifyOTP} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="otp">کد تأیید</Label>
<Input
id="otp"
type="text"
placeholder="123456"
value={otp}
onChange={(e) => setOtp(e.target.value)}
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال تأیید...
</>
) : (
'تأیید کد'
)}
</Button>
<Button
type="button"
variant="ghost"
className="w-full"
onClick={() => setOtpSent(false)}
disabled={isLoading}
>
تغییر شماره
</Button>
</form>
)}
</>
)}
{/* Google Login */}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">یا</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleLogin}
disabled={isLoading}
>
<svg className="ml-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
ورود با گوگل
</Button>
<div className="text-center text-sm">
حساب کاربری ندارید؟{' '}
<Link to="/signup" className="text-primary hover:underline">
ثبت نام
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,245 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth';
import { useTheme } from '@/hooks/useTheme';
import { useToast } from '@/components/ui/use-toast';
import { Loader2, UserPlus, Moon, Sun } from 'lucide-react';
export default function SignupPage() {
const navigate = useNavigate();
const { signUp, signInWithGoogle } = useAuth();
const { toggleTheme, isDark } = useTheme();
const { toast } = useToast();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password || !confirmPassword) {
toast({
title: 'خطا',
description: 'لطفا تمام فیلدها را پر کنید',
variant: 'destructive'
});
return;
}
if (password !== confirmPassword) {
toast({
title: 'خطا',
description: 'رمز عبور و تکرار آن مطابقت ندارند',
variant: 'destructive'
});
return;
}
if (password.length < 6) {
toast({
title: 'خطا',
description: 'رمز عبور باید حداقل 6 کاراکتر باشد',
variant: 'destructive'
});
return;
}
setIsLoading(true);
try {
const result = await signUp(email, password);
if (result.error) {
let errorMessage = 'خطا در ثبت نام';
if (result.error.message.includes('User already registered')) {
errorMessage = 'این ایمیل قبلاً ثبت شده است. لطفا وارد شوید.';
} else if (result.error.message.includes('Password')) {
errorMessage = 'رمز عبور باید حداقل 6 کاراکتر باشد.';
} else {
errorMessage = result.error.message;
}
toast({
title: 'خطا',
description: errorMessage,
variant: 'destructive'
});
} else if (result.data) {
// Check if email confirmation is required
if (result.data.user && !result.data.session) {
toast({
title: 'موفق',
description: 'حساب کاربری ایجاد شد. لطفا ایمیل خود را بررسی کنید و لینک تأیید را کلیک کنید.',
duration: 5000
});
navigate('/login');
} else if (result.data.session) {
toast({
title: 'موفق',
description: 'حساب کاربری ایجاد شد. در حال ورود...'
});
navigate('/account');
}
}
} catch (error: any) {
let errorMessage = 'خطا در ثبت نام';
if (error.message) {
if (error.message.includes('User already registered')) {
errorMessage = 'این ایمیل قبلاً ثبت شده است. لطفا وارد شوید.';
} else if (error.message.includes('Password')) {
errorMessage = 'رمز عبور باید حداقل 6 کاراکتر باشد.';
} else {
errorMessage = error.message;
}
}
toast({
title: 'خطا',
description: errorMessage,
variant: 'destructive'
});
} finally {
setIsLoading(false);
}
};
const handleGoogleSignup = async () => {
setIsLoading(true);
try {
await signInWithGoogle();
// Redirect happens automatically
} catch (error: any) {
toast({
title: 'خطا',
description: error.message || 'خطا در ثبت نام با گوگل',
variant: 'destructive'
});
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4 relative">
{/* Dark Mode Toggle Button */}
<button
onClick={toggleTheme}
className="absolute top-4 left-4 p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-input hover:bg-accent transition-colors"
aria-label={isDark ? 'تغییر به حالت روشن' : 'تغییر به حالت تاریک'}
>
{isDark ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</button>
<Card className="w-full max-w-sm">
<CardHeader className="space-y-1">
<div className="flex justify-center mb-4">
<UserPlus className="h-12 w-12 text-primary" />
</div>
<CardTitle className="text-2xl text-center">ثبت نام</CardTitle>
<CardDescription className="text-center">
حساب کاربری جدید ایجاد کنید
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<form onSubmit={handleSignup} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">ایمیل</Label>
<Input
id="email"
type="email"
placeholder="example@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">رمز عبور</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">تکرار رمز عبور</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ثبت نام...
</>
) : (
'ثبت نام'
)}
</Button>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">یا</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleGoogleSignup}
disabled={isLoading}
>
<svg className="ml-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
ثبت نام با گوگل
</Button>
<div className="text-center text-sm">
قبلا ثبت نام کردهاید؟{' '}
<Link to="/login" className="text-primary hover:underline">
ورود
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

167
src/pages/Chat/ChatPage.tsx Normal file
View File

@ -0,0 +1,167 @@
import { useState, useRef, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAuth } from '@/hooks/useAuth';
import { useChat } from '@/hooks/useChat';
import { useToast } from '@/components/ui/use-toast';
import { Send, MessageSquare, Loader2 } from 'lucide-react';
export default function ChatPage() {
const { user } = useAuth();
const { messages, loading, sendMessage } = useChat();
const { toast } = useToast();
const [message, setMessage] = useState('');
const [isSending, setIsSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast({
title: 'خطا',
description: 'لطفا ابتدا وارد حساب کاربری شوید',
variant: 'destructive'
});
return;
}
if (!message.trim()) {
return;
}
setIsSending(true);
try {
await sendMessage(message, user.id);
setMessage('');
} catch (error) {
toast({
title: 'خطا',
description: 'خطا در ارسال پیام',
variant: 'destructive'
});
} finally {
setIsSending(false);
}
};
const getUserDisplayName = (msg: typeof messages[0]) => {
if (msg.user_id === user?.id) return 'شما';
// For other users, show a simplified identifier
// In production, you'd fetch from a profiles table
if (msg.user?.full_name) return msg.user.full_name;
if (msg.user?.email) return msg.user.email;
if (msg.user?.phone) return msg.user.phone;
return `کاربر ${msg.user_id.substring(0, 8)}`;
};
const getUserInitials = (msg: typeof messages[0]) => {
const name = getUserDisplayName(msg);
if (name === 'شما') return 'ش';
if (name.length >= 2) return name.substring(0, 2);
return 'ک';
};
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
<div className="mx-auto max-w-sm">
<div className="mb-4">
<h1 className="text-2xl font-bold mb-2">چت</h1>
<p className="text-sm text-muted-foreground">ارسال پیام و ارتباط با دیگران</p>
</div>
<Card className="flex flex-col h-[calc(100vh-14rem)]">
<CardHeader>
<div className="flex items-center gap-2">
<MessageSquare className="h-6 w-6 text-primary" />
<CardTitle className="text-lg">پیامها</CardTitle>
</div>
<CardDescription className="text-sm">
{loading ? 'در حال بارگذاری...' : `${messages.length} پیام`}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col overflow-hidden">
{loading ? (
<div className="flex items-center justify-center flex-1">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<>
<div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
{messages.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
هیچ پیامی وجود ندارد. اولین پیام را ارسال کنید!
</div>
) : (
messages.map((msg) => {
const isOwnMessage = msg.user_id === user?.id;
return (
<div
key={msg.id}
className={`flex gap-3 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
>
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={msg.user?.avatar_url} />
<AvatarFallback className="text-xs">
{getUserInitials(msg)}
</AvatarFallback>
</Avatar>
<div
className={`flex-1 ${isOwnMessage ? 'text-left' : 'text-right'}`}
>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-xs font-medium">
{getUserDisplayName(msg)}
</span>
<span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleTimeString('fa-IR', {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div
className={`rounded-lg p-3 ${
isOwnMessage
? 'bg-primary text-primary-foreground ml-auto'
: 'bg-muted'
}`}
style={{ maxWidth: '85%', marginRight: isOwnMessage ? '0' : 'auto', marginLeft: isOwnMessage ? 'auto' : '0' }}
>
<p className="text-xs whitespace-pre-wrap break-words">{msg.content}</p>
</div>
</div>
</div>
);
})
)}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSend} className="flex gap-2">
<Input
placeholder="پیام خود را بنویسید..."
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={isSending || !user}
/>
<Button type="submit" disabled={isSending || !user || !message.trim()}>
{isSending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</form>
</>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,96 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { Home, User, MessageSquare, FileText } from 'lucide-react';
export default function MainPage() {
const { user, isAuthenticated } = useAuth();
const navigate = useNavigate();
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
<div className="mx-auto max-w-sm">
<div className="mb-6 text-center">
<h1 className="text-2xl font-bold mb-2">خوش آمدید</h1>
<p className="text-sm text-muted-foreground">
{isAuthenticated && user
? `سلام ${user.email || user.phone || 'کاربر'}`
: 'به اپلیکیشن پیشرفته PWA خوش آمدید'}
</p>
</div>
<div className="grid gap-4 grid-cols-1">
<Card className="cursor-pointer transition-all hover:shadow-lg" onClick={() => navigate('/account')}>
<CardHeader>
<User className="mb-2 h-7 w-7 text-primary" />
<CardTitle className="text-lg">حساب کاربری</CardTitle>
<CardDescription className="text-sm">مدیریت پروفایل و تنظیمات</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" className="w-full">
مشاهده
</Button>
</CardContent>
</Card>
<Card className="cursor-pointer transition-all hover:shadow-lg" onClick={() => navigate('/request')}>
<CardHeader>
<FileText className="mb-2 h-7 w-7 text-primary" />
<CardTitle className="text-lg">درخواستها</CardTitle>
<CardDescription className="text-sm">ایجاد و مدیریت درخواستها</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" className="w-full">
مشاهده
</Button>
</CardContent>
</Card>
<Card className="cursor-pointer transition-all hover:shadow-lg" onClick={() => navigate('/chat')}>
<CardHeader>
<MessageSquare className="mb-2 h-7 w-7 text-primary" />
<CardTitle className="text-lg">چت</CardTitle>
<CardDescription className="text-sm">ارسال پیام و ارتباط</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" className="w-full">
مشاهده
</Button>
</CardContent>
</Card>
</div>
<Card className="mt-6">
<CardHeader>
<Home className="mb-2 h-7 w-7 text-primary" />
<CardTitle className="text-lg">داشبورد اصلی</CardTitle>
<CardDescription className="text-sm">نمای کلی از عملکرد سیستم</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 grid-cols-1">
<div className="rounded-lg border p-4">
<div className="text-xl font-bold">0</div>
<div className="text-xs text-muted-foreground">درخواستهای فعال</div>
</div>
<div className="rounded-lg border p-4">
<div className="text-xl font-bold">0</div>
<div className="text-xs text-muted-foreground">پیامهای جدید</div>
</div>
<div className="rounded-lg border p-4">
<div className="text-xl font-bold">
{isAuthenticated ? '✓' : '✗'}
</div>
<div className="text-xs text-muted-foreground">وضعیت ورود</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,186 @@
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth';
import { useRequests } from '@/hooks/useRequests';
import { useToast } from '@/components/ui/use-toast';
import { Plus, FileText, Loader2 } from 'lucide-react';
export default function RequestPage() {
const { user } = useAuth();
const { requests, loading, createRequest } = useRequests();
const { toast } = useToast();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) {
toast({
title: 'خطا',
description: 'لطفا ابتدا وارد حساب کاربری شوید',
variant: 'destructive'
});
return;
}
if (!title.trim() || !description.trim()) {
toast({
title: 'خطا',
description: 'لطفا تمام فیلدها را پر کنید',
variant: 'destructive'
});
return;
}
setIsSubmitting(true);
try {
await createRequest(title, description, user.id);
toast({
title: 'موفق',
description: 'درخواست با موفقیت ایجاد شد'
});
setTitle('');
setDescription('');
} catch (error) {
toast({
title: 'خطا',
description: 'خطا در ایجاد درخواست',
variant: 'destructive'
});
} finally {
setIsSubmitting(false);
}
};
const getStatusBadge = (status: string) => {
const variants = {
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
in_progress: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
};
const labels = {
pending: 'در انتظار',
in_progress: 'در حال انجام',
completed: 'تکمیل شده'
};
return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${variants[status as keyof typeof variants] || ''}`}>
{labels[status as keyof typeof labels] || status}
</span>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
<div className="mx-auto max-w-sm">
<div className="mb-4">
<h1 className="text-2xl font-bold mb-2">درخواستها</h1>
<p className="text-sm text-muted-foreground">ایجاد و مدیریت درخواستهای خود</p>
</div>
<div className="grid gap-4 grid-cols-1">
<Card>
<CardHeader>
<FileText className="mb-2 h-7 w-7 text-primary" />
<CardTitle className="text-lg">ایجاد درخواست جدید</CardTitle>
<CardDescription className="text-sm">فرم ایجاد درخواست جدید</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">عنوان</Label>
<Input
id="title"
placeholder="عنوان درخواست"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isSubmitting}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">توضیحات</Label>
<textarea
id="description"
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="توضیحات درخواست"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
/>
</div>
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ایجاد...
</>
) : (
<>
<Plus className="ml-2 h-4 w-4" />
ایجاد درخواست
</>
)}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">فهرست درخواستها</CardTitle>
<CardDescription className="text-sm">
{loading ? 'در حال بارگذاری...' : `${requests.length} درخواست موجود است`}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : requests.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
درخواستی وجود ندارد
</div>
) : (
<div className="space-y-3">
{requests.map((request) => (
<Card key={request.id} className="border">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base flex-1">{request.title}</CardTitle>
{getStatusBadge(request.status)}
</div>
<CardDescription>
{new Date(request.created_at).toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{request.description}
</p>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

132
src/services/supabase.ts Normal file
View File

@ -0,0 +1,132 @@
import { createClient } from '@supabase/supabase-js';
import type { SupabaseClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '';
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || '';
// Check if Supabase is configured
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
// Create a mock Supabase client for offline mode
const createMockClient = (): SupabaseClient => {
const mockClient = {
auth: {
getSession: async () => ({ data: { session: null }, error: null }),
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
signInWithOAuth: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
signInWithOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
verifyOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
signUp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
signInWithPassword: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
signOut: async () => ({ error: null })
},
from: () => ({
select: () => ({
order: () => ({ limit: async () => ({ data: [], error: null }) }),
insert: () => ({ select: () => ({ single: async () => ({ data: null, error: { message: 'Supabase not configured' } }) }) })
}),
on: () => ({ subscribe: () => ({ unsubscribe: () => {} }) }),
insert: () => ({ select: () => ({ single: async () => ({ data: null, error: { message: 'Supabase not configured' } }) }) })
}),
channel: () => ({
on: () => ({
subscribe: () => ({ unsubscribe: () => {} })
})
}),
removeChannel: () => {}
} as unknown as SupabaseClient;
return mockClient;
};
// Create real or mock client based on configuration
export const supabase = isSupabaseConfigured
? createClient(supabaseUrl, supabaseAnonKey, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true
},
global: {
headers: {
'apikey': supabaseAnonKey
}
}
})
: createMockClient();
if (!isSupabaseConfigured) {
console.warn('⚠️ Supabase environment variables are not set. Running in offline/mock mode.');
} else {
// Test connection to Supabase (runs once on module load, but doesn't block)
// Use setTimeout to avoid blocking module initialization
setTimeout(() => {
testSupabaseConnection();
}, 100);
}
// Test Supabase connection without needing tables
async function testSupabaseConnection() {
if (!isSupabaseConfigured) return;
try {
// Simple health check: try to get current session
// This doesn't require any tables, just checks if we can communicate with Supabase
const { data, error } = await supabase.auth.getSession();
if (error) {
console.error('❌ خطا در اتصال به Supabase:', error.message);
console.log('📋 اطلاعات اتصال:');
console.log(' URL:', supabaseUrl);
console.log(' Key:', supabaseAnonKey ? `${supabaseAnonKey.substring(0, 20)}...` : 'not set');
console.log('💡 نکته: اگر URL شما custom domain است، مطمئن شوید که به درستی پیکربندی شده است.');
} else {
console.log('✅ اتصال به Supabase با موفقیت برقرار شد!');
console.log('📋 اطلاعات اتصال:');
console.log(' URL:', supabaseUrl);
console.log(' Session:', data.session ? 'فعال' : 'غیرفعال (کاربر لاگین نشده)');
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
console.error('❌ خطا در تست اتصال Supabase:', errorMessage);
console.log('📋 لطفاً بررسی کنید:');
console.log(' 1. فایل .env درست تنظیم شده است؟');
console.log(' 2. URL و Key صحیح هستند؟');
console.log(' 3. اینترنت متصل است؟');
console.log(' 4. اگر از custom domain استفاده می‌کنید، مطمئن شوید که CORS و تنظیمات درست است.');
}
}
// Database types (extend as needed)
export type Profile = {
id: string;
email?: string;
phone?: string;
full_name?: string;
avatar_url?: string;
created_at?: string;
};
export type Request = {
id: string;
user_id: string;
title: string;
description: string;
status: 'pending' | 'in_progress' | 'completed';
created_at: string;
updated_at: string;
};
export type Message = {
id: string;
user_id: string;
content: string;
created_at: string;
user?: {
email?: string;
phone?: string;
full_name?: string;
avatar_url?: string;
};
};

80
src/styles/globals.css Normal file
View File

@ -0,0 +1,80 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
/* Custom Font - فونت سفارشی */
@font-face {
font-family: 'IRAN Rounded';
src: url('/fonts/IRAN Rounded.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@layer base {
* {
border-color: hsl(var(--border));
}
body {
@apply bg-background text-foreground;
font-family: 'IRAN Rounded', 'Tahoma', 'Arial', system-ui, -apple-system, sans-serif;
}
}
/* RTL Support */
[dir='rtl'] {
direction: rtl;
text-align: right;
}
[dir='ltr'] {
direction: ltr;
text-align: left;
}

17
src/utils/cn.ts Normal file
View File

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

38
src/utils/constants.ts Normal file
View File

@ -0,0 +1,38 @@
// Application constants
export const APP_NAME = 'PWA React App';
export const APP_DESCRIPTION = 'Production-ready Progressive Web App';
// Route paths
export const ROUTES = {
HOME: '/',
ACCOUNT: '/account',
REQUEST: '/request',
CHAT: '/chat',
LOGIN: '/login',
SIGNUP: '/signup'
} as const;
// Request statuses
export const REQUEST_STATUS = {
PENDING: 'pending',
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed'
} as const;
// Toast durations
export const TOAST_DURATION = {
SHORT: 3000,
MEDIUM: 5000,
LONG: 7000
} as const;

21
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_ANON_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

53
tailwind.config.js Normal file
View File

@ -0,0 +1,53 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ['class'],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
},
},
plugins: [require('tailwindcss-animate')],
};

32
tsconfig.json Normal file
View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

53
vite.config.ts Normal file
View File

@ -0,0 +1,53 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'mask-icon.svg'],
manifest: {
name: 'PWA React App',
short_name: 'PWA App',
description: 'Production-ready Progressive Web App',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
scope: '/',
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable'
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
}
})
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
}
});