first commit
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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) مراجعه کنید.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Generated
+9664
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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/` قرار گیرند.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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.
@@ -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` را بهروز کنید.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
// Atom برای مدیریت theme
|
||||||
|
export const themeAtom = atom<Theme>('light');
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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}</>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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 };
|
||||||
@@ -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/) استفاده کنید.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export * from '../utils/cn';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Vendored
+21
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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')],
|
||||||
|
};
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user