first commit
This commit is contained in:
commit
948238b6d9
|
|
@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 باشد
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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 یا تیم پشتیبانی کمک بگیرید.
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
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 |
|
|
@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue