first commit
This commit is contained in:
+92
@@ -0,0 +1,92 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { Navbar } from '@/components/Navbar';
|
||||
import { isSupabaseConfigured } from '@/services/supabase';
|
||||
import { WifiOff } from 'lucide-react';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const MainPage = lazy(() => import('@/pages/Main/MainPage'));
|
||||
const AccountPage = lazy(() => import('@/pages/Account/AccountPage'));
|
||||
const RequestPage = lazy(() => import('@/pages/Request/RequestPage'));
|
||||
const ChatPage = lazy(() => import('@/pages/Chat/ChatPage'));
|
||||
const LoginPage = lazy(() => import('@/pages/Auth/Login/LoginPage'));
|
||||
const SignupPage = lazy(() => import('@/pages/Auth/Signup/SignupPage'));
|
||||
|
||||
// Loading component
|
||||
const PageLoader = () => (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
// Initialize theme
|
||||
useTheme();
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Offline Mode Banner */}
|
||||
{!isSupabaseConfigured && (
|
||||
<div className="bg-yellow-500/10 border-b border-yellow-500/20 px-4 py-2">
|
||||
<div className="container mx-auto flex items-center gap-2 text-sm text-yellow-600 dark:text-yellow-400">
|
||||
<WifiOff className="h-4 w-4" />
|
||||
<span>حالت آفلاین: Supabase پیکربندی نشده است. برای استفاده کامل از ویژگیها، فایل .env را تنظیم کنید.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Navbar />
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MainPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AccountPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/request"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<RequestPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/chat"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ChatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<Toaster />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { atom } from 'jotai';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
|
||||
export const sessionAtom = atom<Session | null>(null);
|
||||
export const userAtom = atom<User | null>((get) => {
|
||||
const session = get(sessionAtom);
|
||||
return session?.user ?? null;
|
||||
});
|
||||
|
||||
export const isAuthenticatedAtom = atom<boolean>((get) => {
|
||||
const session = get(sessionAtom);
|
||||
return !!session?.user;
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { atom } from 'jotai';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
// Atom برای مدیریت theme
|
||||
export const themeAtom = atom<Theme>('light');
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Home, User, MessageSquare, FileText, LogOut, Moon, Sun } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Navbar() {
|
||||
const { isAuthenticated, signOut } = useAuth();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||||
|
||||
const handleSignOut = async () => {
|
||||
await signOut();
|
||||
navigate('/');
|
||||
setShowLogoutConfirm(false);
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/', icon: Home, label: 'خانه' },
|
||||
{ to: '/request', icon: FileText, label: 'درخواستها' },
|
||||
{ to: '/chat', icon: MessageSquare, label: 'چت' },
|
||||
{ to: '/account', icon: User, label: 'حساب' }
|
||||
];
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bottom Navigation Bar */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 safe-area-inset-bottom">
|
||||
<div className="mx-auto max-w-sm">
|
||||
<div className="flex items-center justify-around h-16 px-2">
|
||||
{navLinks.map((link) => {
|
||||
const Icon = link.icon;
|
||||
const active = isActive(link.to);
|
||||
return (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className={`relative flex flex-col items-center justify-center gap-1 flex-1 h-full transition-colors ${
|
||||
active
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${active ? 'scale-110' : ''} transition-transform`} />
|
||||
<span className="text-xs font-medium">{link.label}</span>
|
||||
{active && (
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-8 h-0.5 bg-primary rounded-t-full" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex flex-col items-center justify-center gap-1 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={isDark ? 'تغییر به حالت روشن' : 'تغییر به حالت تاریک'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{isDark ? 'روشن' : 'تاریک'}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(true)}
|
||||
className="flex flex-col items-center justify-center gap-1 flex-1 h-full text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span className="text-xs font-medium">خروج</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Logout Confirmation Modal */}
|
||||
{showLogoutConfirm && (
|
||||
<div className="fixed inset-0 z-[60] bg-black/50 flex items-end justify-center p-4">
|
||||
<div className="bg-background rounded-t-2xl w-full max-w-sm p-6 space-y-4 shadow-lg">
|
||||
<h3 className="text-lg font-semibold">خروج از حساب کاربری</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
آیا مطمئن هستید که میخواهید خارج شوید؟
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowLogoutConfirm(false)}
|
||||
className="flex-1 px-4 py-2 rounded-lg border border-input bg-background hover:bg-accent text-sm font-medium transition-colors"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="flex-1 px-4 py-2 rounded-lg bg-destructive text-destructive-foreground hover:bg-destructive/90 text-sm font-medium transition-colors"
|
||||
>
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAtom } from 'jotai';
|
||||
import { isAuthenticatedAtom } from '@/atoms/auth';
|
||||
import { isSupabaseConfigured } from '@/services/supabase';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||
const [isAuthenticated] = useAtom(isAuthenticatedAtom);
|
||||
const location = useLocation();
|
||||
|
||||
// In offline mode, allow access without authentication
|
||||
if (!isSupabaseConfigured) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn('aspect-square h-full w-full', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from 'react';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border bg-background text-foreground',
|
||||
destructive:
|
||||
'destructive group border-destructive bg-destructive text-destructive-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute left-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold', className)} {...props} />
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from './toast';
|
||||
import { useToast } from './use-toast';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as React from 'react';
|
||||
import type { ToastActionElement, ToastProps } from './toast';
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 5000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: 'ADD_TOAST',
|
||||
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||
REMOVE_TOAST: 'REMOVE_TOAST'
|
||||
} as const;
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes;
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType['ADD_TOAST'];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType['UPDATE_TOAST'];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType['DISMISS_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
}
|
||||
| {
|
||||
type: ActionType['REMOVE_TOAST'];
|
||||
toastId?: ToasterToast['id'];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: 'REMOVE_TOAST',
|
||||
toastId: toastId
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT)
|
||||
};
|
||||
|
||||
case 'UPDATE_TOAST':
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
)
|
||||
};
|
||||
|
||||
case 'DISMISS_TOAST': {
|
||||
const { toastId } = action;
|
||||
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false
|
||||
}
|
||||
: t
|
||||
)
|
||||
};
|
||||
}
|
||||
case 'REMOVE_TOAST':
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: []
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId)
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, 'id'>;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: 'UPDATE_TOAST',
|
||||
toast: { ...props, id }
|
||||
});
|
||||
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: 'ADD_TOAST',
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId })
|
||||
};
|
||||
}
|
||||
|
||||
export { useToast, toast };
|
||||
@@ -0,0 +1,126 @@
|
||||
# راهنمای راهاندازی
|
||||
|
||||
## پیشنیازها
|
||||
|
||||
- Node.js 18+ و npm/yarn/pnpm
|
||||
- حساب Supabase
|
||||
|
||||
## مراحل نصب
|
||||
|
||||
### 1. نصب وابستگیها
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. تنظیمات محیط
|
||||
|
||||
یک فایل `.env` در ریشه پروژه ایجاد کنید:
|
||||
|
||||
```env
|
||||
VITE_SUPABASE_URL=your-supabase-project-url
|
||||
VITE_SUPABASE_ANON_KEY=your-supabase-anon-key
|
||||
```
|
||||
|
||||
مقادیر را از [Supabase Dashboard](https://supabase.com/dashboard) دریافت کنید.
|
||||
|
||||
### 3. راهاندازی Database
|
||||
|
||||
در Supabase SQL Editor، این کدها را اجرا کنید:
|
||||
|
||||
```sql
|
||||
-- ایجاد جدول requests
|
||||
create table requests (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
user_id uuid references auth.users(id) on delete cascade not null,
|
||||
title text not null,
|
||||
description text not null,
|
||||
status text default 'pending' check (status in ('pending', 'in_progress', 'completed')),
|
||||
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
|
||||
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
|
||||
);
|
||||
|
||||
alter table requests enable row level security;
|
||||
|
||||
create policy "Anyone can view requests" on requests
|
||||
for select using (true);
|
||||
|
||||
create policy "Users can insert their own requests" on requests
|
||||
for insert with check (auth.uid() = user_id);
|
||||
|
||||
create policy "Users can update their own requests" on requests
|
||||
for update using (auth.uid() = user_id);
|
||||
|
||||
-- ایجاد جدول messages
|
||||
create table messages (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
user_id uuid references auth.users(id) on delete cascade not null,
|
||||
content text not null,
|
||||
created_at timestamp with time zone default timezone('utc'::text, now()) not null
|
||||
);
|
||||
|
||||
alter table messages enable row level security;
|
||||
|
||||
create policy "Anyone can view messages" on messages
|
||||
for select using (true);
|
||||
|
||||
create policy "Authenticated users can insert messages" on messages
|
||||
for insert with check (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### 4. فعالسازی Realtime
|
||||
|
||||
1. به Supabase Dashboard بروید
|
||||
2. Database > Replication
|
||||
3. Realtime را برای جداول `requests` و `messages` فعال کنید
|
||||
|
||||
### 5. تنظیمات Google OAuth (اختیاری)
|
||||
|
||||
1. در Supabase Dashboard به Authentication > Providers بروید
|
||||
2. Google را فعال کنید
|
||||
3. Client ID و Client Secret را از Google Cloud Console دریافت و وارد کنید
|
||||
|
||||
### 6. اجرای پروژه
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
پروژه در آدرس `http://localhost:5173` اجرا میشود.
|
||||
|
||||
## ساخت برای Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
فایلهای خروجی در فولدر `dist/` قرار میگیرند.
|
||||
|
||||
## اضافه کردن فونت IRANSans
|
||||
|
||||
برای استفاده از فونت IRANSans:
|
||||
|
||||
1. فایلهای فونت را از [این آدرس](https://github.com/font-store/IRANSans) دانلود کنید
|
||||
2. فایلهای `.woff2` را در فولدر `public/fonts/` قرار دهید
|
||||
3. نام فایلها باید `IRANSansWeb.woff2` و `IRANSansWeb_Bold.woff2` باشند
|
||||
|
||||
## اضافه کردن آیکونهای PWA
|
||||
|
||||
برای کامل کردن PWA:
|
||||
|
||||
1. آیکونهای 192x192 و 512x512 پیکسل ایجاد کنید
|
||||
2. آنها را در فولدر `public/` با نامهای `pwa-192x192.png` و `pwa-512x512.png` قرار دهید
|
||||
3. همچنین `apple-touch-icon.png` (180x180) و `mask-icon.svg` را اضافه کنید
|
||||
|
||||
میتوانید از [RealFaviconGenerator](https://realfavicongenerator.net/) استفاده کنید.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { sessionAtom, userAtom, isAuthenticatedAtom } from '@/atoms/auth';
|
||||
import { supabase, isSupabaseConfigured } from '@/services/supabase';
|
||||
|
||||
export function useAuth() {
|
||||
const [session, setSession] = useAtom(sessionAtom);
|
||||
const [user] = useAtom(userAtom);
|
||||
const [isAuthenticated] = useAtom(isAuthenticatedAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSupabaseConfigured) {
|
||||
// In offline mode, set session to null
|
||||
setSession(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get initial session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setSession(session);
|
||||
}).catch((error) => {
|
||||
console.error('Error getting session:', error);
|
||||
setSession(null);
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const {
|
||||
data: { subscription }
|
||||
} = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setSession(session);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [setSession]);
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/account`
|
||||
}
|
||||
});
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
const signInWithPhone = async (phone: string) => {
|
||||
const { error } = await supabase.auth.signInWithOtp({
|
||||
phone,
|
||||
options: {
|
||||
channel: 'sms'
|
||||
}
|
||||
});
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
const verifyOTP = async (phone: string, token: string) => {
|
||||
const { error } = await supabase.auth.verifyOtp({
|
||||
phone,
|
||||
token,
|
||||
type: 'sms'
|
||||
});
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
const signUp = async (email: string, password: string) => {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/account`
|
||||
}
|
||||
});
|
||||
if (error) throw error;
|
||||
return { data, error: null };
|
||||
};
|
||||
|
||||
const signIn = async (email: string, password: string) => {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) throw error;
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isAuthenticated,
|
||||
signInWithGoogle,
|
||||
signInWithPhone,
|
||||
verifyOTP,
|
||||
signUp,
|
||||
signIn,
|
||||
signOut
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase, isSupabaseConfigured, type Message } from '@/services/supabase';
|
||||
|
||||
export function useChat() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadMessages();
|
||||
|
||||
if (!isSupabaseConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to real-time messages
|
||||
const channel = supabase
|
||||
.channel('messages')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'messages'
|
||||
},
|
||||
(payload) => {
|
||||
setMessages((prev) => [payload.new as Message, ...prev]);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadMessages = async () => {
|
||||
if (!isSupabaseConfigured) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// For now, user data will be fetched separately in the component
|
||||
// You can create a profiles table and join it here if needed
|
||||
setMessages((data || []) as Message[]);
|
||||
} catch (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
setMessages([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (content: string, userId: string) => {
|
||||
if (!isSupabaseConfigured) {
|
||||
throw new Error('Supabase is not configured. Cannot send messages in offline mode.');
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.insert([
|
||||
{
|
||||
user_id: userId,
|
||||
content
|
||||
}
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { messages, loading, sendMessage };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase, isSupabaseConfigured, type Request } from '@/services/supabase';
|
||||
|
||||
export function useRequests() {
|
||||
const [requests, setRequests] = useState<Request[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
|
||||
if (!isSupabaseConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to real-time updates
|
||||
const channel = supabase
|
||||
.channel('requests_changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'requests'
|
||||
},
|
||||
() => {
|
||||
loadRequests();
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadRequests = async () => {
|
||||
if (!isSupabaseConfigured) {
|
||||
setRequests([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('requests')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setRequests(data || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading requests:', error);
|
||||
setRequests([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createRequest = async (title: string, description: string, userId: string) => {
|
||||
if (!isSupabaseConfigured) {
|
||||
throw new Error('Supabase is not configured. Cannot create requests in offline mode.');
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('requests')
|
||||
.insert([
|
||||
{
|
||||
user_id: userId,
|
||||
title,
|
||||
description,
|
||||
status: 'pending'
|
||||
}
|
||||
])
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error creating request:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return { requests, loading, createRequest, loadRequests };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { themeAtom, type Theme } from '@/atoms/theme';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useAtom(themeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
// بارگذاری theme از localStorage در اولین بار
|
||||
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
||||
if (savedTheme && (savedTheme === 'light' || savedTheme === 'dark')) {
|
||||
setTheme(savedTheme);
|
||||
} else {
|
||||
// بررسی ترجیح سیستم
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
setTheme(prefersDark ? 'dark' : 'light');
|
||||
}
|
||||
}, [setTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
// اعمال theme به document
|
||||
const root = document.documentElement;
|
||||
if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else {
|
||||
root.classList.remove('dark');
|
||||
}
|
||||
// ذخیره در localStorage
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev: Theme) => (prev === 'light' ? 'dark' : 'light'));
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export * from '../utils/cn';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogOut, User as UserIcon, Mail, Phone } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
|
||||
export default function AccountPage() {
|
||||
const { user, signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSignOut = async () => {
|
||||
try {
|
||||
await signOut();
|
||||
toast({
|
||||
title: 'خروج موفق',
|
||||
description: 'با موفقیت خارج شدید'
|
||||
});
|
||||
navigate('/');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'خطا در خروج از حساب کاربری',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = () => {
|
||||
if (user?.email) {
|
||||
return user.email.substring(0, 2).toUpperCase();
|
||||
}
|
||||
if (user?.phone) {
|
||||
return user.phone.slice(-2);
|
||||
}
|
||||
return 'U';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
|
||||
<div className="mx-auto max-w-sm">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold mb-2">حساب کاربری</h1>
|
||||
<p className="text-sm text-muted-foreground">مدیریت اطلاعات و تنظیمات حساب کاربری</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>اطلاعات کاربری</CardTitle>
|
||||
<CardDescription>جزئیات حساب کاربری شما</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={user?.user_metadata?.avatar_url} alt={user?.email || 'User'} />
|
||||
<AvatarFallback className="text-lg">{getUserInitials()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold mb-2 truncate">
|
||||
{user?.user_metadata?.full_name || user?.email || user?.phone || 'کاربر'}
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
{user?.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{user.email}</span>
|
||||
</div>
|
||||
)}
|
||||
{user?.phone && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{user.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">شناسه کاربری</div>
|
||||
<div className="text-sm text-muted-foreground font-mono">
|
||||
{user?.id?.substring(0, 8)}...
|
||||
</div>
|
||||
</div>
|
||||
<UserIcon className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{user?.created_at && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">تاریخ عضویت</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(user.created_at).toLocaleDateString('fa-IR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
<LogOut className="ml-2 h-4 w-4" />
|
||||
خروج از حساب کاربری
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, Mail, Phone, LogIn, Moon, Sun } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signIn, signInWithGoogle, signInWithPhone, verifyOTP } = useAuth();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { toast } = useToast();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
const [authMethod, setAuthMethod] = useState<'email' | 'phone' | 'google'>('email');
|
||||
|
||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'لطفا ایمیل و رمز عبور را وارد کنید',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signIn(email, password);
|
||||
toast({
|
||||
title: 'موفق',
|
||||
description: 'با موفقیت وارد شدید'
|
||||
});
|
||||
navigate('/account');
|
||||
} catch (error: any) {
|
||||
let errorMessage = 'خطا در ورود';
|
||||
|
||||
if (error.message) {
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
errorMessage = 'ایمیل یا رمز عبور اشتباه است. لطفا دوباره تلاش کنید یا ثبت نام کنید.';
|
||||
} else if (error.message.includes('Email not confirmed')) {
|
||||
errorMessage = 'لطفا ابتدا ایمیل خود را تأیید کنید.';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: errorMessage,
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
// Redirect happens automatically
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: error.message || 'خطا در ورود با گوگل',
|
||||
variant: 'destructive'
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!phone) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'لطفا شماره تلفن را وارد کنید',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signInWithPhone(phone);
|
||||
setOtpSent(true);
|
||||
toast({
|
||||
title: 'موفق',
|
||||
description: 'کد تأیید ارسال شد'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: error.message || 'خطا در ارسال کد',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!otp) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'لطفا کد تأیید را وارد کنید',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await verifyOTP(phone, otp);
|
||||
toast({
|
||||
title: 'موفق',
|
||||
description: 'با موفقیت وارد شدید'
|
||||
});
|
||||
navigate('/account');
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: error.message || 'کد تأیید نامعتبر است',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4 relative">
|
||||
{/* Dark Mode Toggle Button */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="absolute top-4 left-4 p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-input hover:bg-accent transition-colors"
|
||||
aria-label={isDark ? 'تغییر به حالت روشن' : 'تغییر به حالت تاریک'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex justify-center mb-4">
|
||||
<LogIn className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">ورود</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
به حساب کاربری خود وارد شوید
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Auth Method Selector */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={authMethod === 'email' ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setAuthMethod('email');
|
||||
setOtpSent(false);
|
||||
}}
|
||||
>
|
||||
<Mail className="ml-2 h-4 w-4" />
|
||||
ایمیل
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={authMethod === 'phone' ? 'default' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setAuthMethod('phone');
|
||||
setOtpSent(false);
|
||||
}}
|
||||
>
|
||||
<Phone className="ml-2 h-4 w-4" />
|
||||
تلفن
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Email Login */}
|
||||
{authMethod === 'email' && (
|
||||
<form onSubmit={handleEmailLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">ایمیل</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">رمز عبور</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ورود...
|
||||
</>
|
||||
) : (
|
||||
'ورود'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Phone OTP Login */}
|
||||
{authMethod === 'phone' && (
|
||||
<>
|
||||
{!otpSent ? (
|
||||
<form onSubmit={handleSendOTP} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">شماره تلفن</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="09123456789"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ارسال...
|
||||
</>
|
||||
) : (
|
||||
'ارسال کد تأیید'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerifyOTP} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="otp">کد تأیید</Label>
|
||||
<Input
|
||||
id="otp"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال تأیید...
|
||||
</>
|
||||
) : (
|
||||
'تأیید کد'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => setOtpSent(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
تغییر شماره
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Google Login */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">یا</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg className="ml-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
ورود با گوگل
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
حساب کاربری ندارید؟{' '}
|
||||
<Link to="/signup" className="text-primary hover:underline">
|
||||
ثبت نام
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, UserPlus, Moon, Sun } from 'lucide-react';
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { signUp, signInWithGoogle } = useAuth();
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const { toast } = useToast();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email || !password || !confirmPassword) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'لطفا تمام فیلدها را پر کنید',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'رمز عبور و تکرار آن مطابقت ندارند',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'رمز عبور باید حداقل 6 کاراکتر باشد',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await signUp(email, password);
|
||||
|
||||
if (result.error) {
|
||||
let errorMessage = 'خطا در ثبت نام';
|
||||
|
||||
if (result.error.message.includes('User already registered')) {
|
||||
errorMessage = 'این ایمیل قبلاً ثبت شده است. لطفا وارد شوید.';
|
||||
} else if (result.error.message.includes('Password')) {
|
||||
errorMessage = 'رمز عبور باید حداقل 6 کاراکتر باشد.';
|
||||
} else {
|
||||
errorMessage = result.error.message;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: errorMessage,
|
||||
variant: 'destructive'
|
||||
});
|
||||
} else if (result.data) {
|
||||
// Check if email confirmation is required
|
||||
if (result.data.user && !result.data.session) {
|
||||
toast({
|
||||
title: 'موفق',
|
||||
description: 'حساب کاربری ایجاد شد. لطفا ایمیل خود را بررسی کنید و لینک تأیید را کلیک کنید.',
|
||||
duration: 5000
|
||||
});
|
||||
navigate('/login');
|
||||
} else if (result.data.session) {
|
||||
toast({
|
||||
title: 'موفق',
|
||||
description: 'حساب کاربری ایجاد شد. در حال ورود...'
|
||||
});
|
||||
navigate('/account');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
let errorMessage = 'خطا در ثبت نام';
|
||||
|
||||
if (error.message) {
|
||||
if (error.message.includes('User already registered')) {
|
||||
errorMessage = 'این ایمیل قبلاً ثبت شده است. لطفا وارد شوید.';
|
||||
} else if (error.message.includes('Password')) {
|
||||
errorMessage = 'رمز عبور باید حداقل 6 کاراکتر باشد.';
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: errorMessage,
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleSignup = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signInWithGoogle();
|
||||
// Redirect happens automatically
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: error.message || 'خطا در ثبت نام با گوگل',
|
||||
variant: 'destructive'
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background to-muted p-4 relative">
|
||||
{/* Dark Mode Toggle Button */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="absolute top-4 left-4 p-2 rounded-lg bg-background/80 backdrop-blur-sm border border-input hover:bg-accent transition-colors"
|
||||
aria-label={isDark ? 'تغییر به حالت روشن' : 'تغییر به حالت تاریک'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader className="space-y-1">
|
||||
<div className="flex justify-center mb-4">
|
||||
<UserPlus className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">ثبت نام</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
حساب کاربری جدید ایجاد کنید
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSignup} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">ایمیل</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="example@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">رمز عبور</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">تکرار رمز عبور</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ثبت نام...
|
||||
</>
|
||||
) : (
|
||||
'ثبت نام'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">یا</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleGoogleSignup}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg className="ml-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
ثبت نام با گوگل
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
قبلا ثبت نام کردهاید؟{' '}
|
||||
<Link to="/login" className="text-primary hover:underline">
|
||||
ورود
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useChat } from '@/hooks/useChat';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Send, MessageSquare, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function ChatPage() {
|
||||
const { user } = useAuth();
|
||||
const { messages, loading, sendMessage } = useChat();
|
||||
const { toast } = useToast();
|
||||
const [message, setMessage] = useState('');
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'لطفا ابتدا وارد حساب کاربری شوید',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
try {
|
||||
await sendMessage(message, user.id);
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'خطا در ارسال پیام',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserDisplayName = (msg: typeof messages[0]) => {
|
||||
if (msg.user_id === user?.id) return 'شما';
|
||||
// For other users, show a simplified identifier
|
||||
// In production, you'd fetch from a profiles table
|
||||
if (msg.user?.full_name) return msg.user.full_name;
|
||||
if (msg.user?.email) return msg.user.email;
|
||||
if (msg.user?.phone) return msg.user.phone;
|
||||
return `کاربر ${msg.user_id.substring(0, 8)}`;
|
||||
};
|
||||
|
||||
const getUserInitials = (msg: typeof messages[0]) => {
|
||||
const name = getUserDisplayName(msg);
|
||||
if (name === 'شما') return 'ش';
|
||||
if (name.length >= 2) return name.substring(0, 2);
|
||||
return 'ک';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
|
||||
<div className="mx-auto max-w-sm">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold mb-2">چت</h1>
|
||||
<p className="text-sm text-muted-foreground">ارسال پیام و ارتباط با دیگران</p>
|
||||
</div>
|
||||
|
||||
<Card className="flex flex-col h-[calc(100vh-14rem)]">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-6 w-6 text-primary" />
|
||||
<CardTitle className="text-lg">پیامها</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-sm">
|
||||
{loading ? 'در حال بارگذاری...' : `${messages.length} پیام`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto space-y-4 mb-4 pr-2">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
هیچ پیامی وجود ندارد. اولین پیام را ارسال کنید!
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
const isOwnMessage = msg.user_id === user?.id;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex gap-3 ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}
|
||||
>
|
||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||
<AvatarImage src={msg.user?.avatar_url} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{getUserInitials(msg)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div
|
||||
className={`flex-1 ${isOwnMessage ? 'text-left' : 'text-right'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-xs font-medium">
|
||||
{getUserDisplayName(msg)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(msg.created_at).toLocaleTimeString('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-lg p-3 ${
|
||||
isOwnMessage
|
||||
? 'bg-primary text-primary-foreground ml-auto'
|
||||
: 'bg-muted'
|
||||
}`}
|
||||
style={{ maxWidth: '85%', marginRight: isOwnMessage ? '0' : 'auto', marginLeft: isOwnMessage ? 'auto' : '0' }}
|
||||
>
|
||||
<p className="text-xs whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
<form onSubmit={handleSend} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="پیام خود را بنویسید..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
disabled={isSending || !user}
|
||||
/>
|
||||
<Button type="submit" disabled={isSending || !user || !message.trim()}>
|
||||
{isSending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Home, User, MessageSquare, FileText } from 'lucide-react';
|
||||
|
||||
export default function MainPage() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
|
||||
<div className="mx-auto max-w-sm">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-2xl font-bold mb-2">خوش آمدید</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isAuthenticated && user
|
||||
? `سلام ${user.email || user.phone || 'کاربر'}`
|
||||
: 'به اپلیکیشن پیشرفته PWA خوش آمدید'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1">
|
||||
<Card className="cursor-pointer transition-all hover:shadow-lg" onClick={() => navigate('/account')}>
|
||||
<CardHeader>
|
||||
<User className="mb-2 h-7 w-7 text-primary" />
|
||||
<CardTitle className="text-lg">حساب کاربری</CardTitle>
|
||||
<CardDescription className="text-sm">مدیریت پروفایل و تنظیمات</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="w-full">
|
||||
مشاهده
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer transition-all hover:shadow-lg" onClick={() => navigate('/request')}>
|
||||
<CardHeader>
|
||||
<FileText className="mb-2 h-7 w-7 text-primary" />
|
||||
<CardTitle className="text-lg">درخواستها</CardTitle>
|
||||
<CardDescription className="text-sm">ایجاد و مدیریت درخواستها</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="w-full">
|
||||
مشاهده
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer transition-all hover:shadow-lg" onClick={() => navigate('/chat')}>
|
||||
<CardHeader>
|
||||
<MessageSquare className="mb-2 h-7 w-7 text-primary" />
|
||||
<CardTitle className="text-lg">چت</CardTitle>
|
||||
<CardDescription className="text-sm">ارسال پیام و ارتباط</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="w-full">
|
||||
مشاهده
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<Home className="mb-2 h-7 w-7 text-primary" />
|
||||
<CardTitle className="text-lg">داشبورد اصلی</CardTitle>
|
||||
<CardDescription className="text-sm">نمای کلی از عملکرد سیستم</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 grid-cols-1">
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-xl font-bold">0</div>
|
||||
<div className="text-xs text-muted-foreground">درخواستهای فعال</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-xl font-bold">0</div>
|
||||
<div className="text-xs text-muted-foreground">پیامهای جدید</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="text-xl font-bold">
|
||||
{isAuthenticated ? '✓' : '✗'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">وضعیت ورود</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useRequests } from '@/hooks/useRequests';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Plus, FileText, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function RequestPage() {
|
||||
const { user } = useAuth();
|
||||
const { requests, loading, createRequest } = useRequests();
|
||||
const { toast } = useToast();
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'لطفا ابتدا وارد حساب کاربری شوید',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title.trim() || !description.trim()) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'لطفا تمام فیلدها را پر کنید',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createRequest(title, description, user.id);
|
||||
toast({
|
||||
title: 'موفق',
|
||||
description: 'درخواست با موفقیت ایجاد شد'
|
||||
});
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'خطا',
|
||||
description: 'خطا در ایجاد درخواست',
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
in_progress: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
};
|
||||
const labels = {
|
||||
pending: 'در انتظار',
|
||||
in_progress: 'در حال انجام',
|
||||
completed: 'تکمیل شده'
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${variants[status as keyof typeof variants] || ''}`}>
|
||||
{labels[status as keyof typeof labels] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background to-muted p-4 pb-20">
|
||||
<div className="mx-auto max-w-sm">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold mb-2">درخواستها</h1>
|
||||
<p className="text-sm text-muted-foreground">ایجاد و مدیریت درخواستهای خود</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<FileText className="mb-2 h-7 w-7 text-primary" />
|
||||
<CardTitle className="text-lg">ایجاد درخواست جدید</CardTitle>
|
||||
<CardDescription className="text-sm">فرم ایجاد درخواست جدید</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">عنوان</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder="عنوان درخواست"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">توضیحات</Label>
|
||||
<textarea
|
||||
id="description"
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="توضیحات درخواست"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
|
||||
در حال ایجاد...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="ml-2 h-4 w-4" />
|
||||
ایجاد درخواست
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">فهرست درخواستها</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{loading ? 'در حال بارگذاری...' : `${requests.length} درخواست موجود است`}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
درخواستی وجود ندارد
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{requests.map((request) => (
|
||||
<Card key={request.id} className="border">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base flex-1">{request.title}</CardTitle>
|
||||
{getStatusBadge(request.status)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
{new Date(request.created_at).toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{request.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '';
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || '';
|
||||
|
||||
// Check if Supabase is configured
|
||||
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
|
||||
|
||||
// Create a mock Supabase client for offline mode
|
||||
const createMockClient = (): SupabaseClient => {
|
||||
const mockClient = {
|
||||
auth: {
|
||||
getSession: async () => ({ data: { session: null }, error: null }),
|
||||
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
|
||||
signInWithOAuth: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
signInWithOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
verifyOtp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
signUp: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
signInWithPassword: async () => ({ data: null, error: { message: 'Supabase not configured' } }),
|
||||
signOut: async () => ({ error: null })
|
||||
},
|
||||
from: () => ({
|
||||
select: () => ({
|
||||
order: () => ({ limit: async () => ({ data: [], error: null }) }),
|
||||
insert: () => ({ select: () => ({ single: async () => ({ data: null, error: { message: 'Supabase not configured' } }) }) })
|
||||
}),
|
||||
on: () => ({ subscribe: () => ({ unsubscribe: () => {} }) }),
|
||||
insert: () => ({ select: () => ({ single: async () => ({ data: null, error: { message: 'Supabase not configured' } }) }) })
|
||||
}),
|
||||
channel: () => ({
|
||||
on: () => ({
|
||||
subscribe: () => ({ unsubscribe: () => {} })
|
||||
})
|
||||
}),
|
||||
removeChannel: () => {}
|
||||
} as unknown as SupabaseClient;
|
||||
|
||||
return mockClient;
|
||||
};
|
||||
|
||||
// Create real or mock client based on configuration
|
||||
export const supabase = isSupabaseConfigured
|
||||
? createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: true
|
||||
},
|
||||
global: {
|
||||
headers: {
|
||||
'apikey': supabaseAnonKey
|
||||
}
|
||||
}
|
||||
})
|
||||
: createMockClient();
|
||||
|
||||
if (!isSupabaseConfigured) {
|
||||
console.warn('⚠️ Supabase environment variables are not set. Running in offline/mock mode.');
|
||||
} else {
|
||||
// Test connection to Supabase (runs once on module load, but doesn't block)
|
||||
// Use setTimeout to avoid blocking module initialization
|
||||
setTimeout(() => {
|
||||
testSupabaseConnection();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Test Supabase connection without needing tables
|
||||
async function testSupabaseConnection() {
|
||||
if (!isSupabaseConfigured) return;
|
||||
|
||||
try {
|
||||
// Simple health check: try to get current session
|
||||
// This doesn't require any tables, just checks if we can communicate with Supabase
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('❌ خطا در اتصال به Supabase:', error.message);
|
||||
console.log('📋 اطلاعات اتصال:');
|
||||
console.log(' URL:', supabaseUrl);
|
||||
console.log(' Key:', supabaseAnonKey ? `${supabaseAnonKey.substring(0, 20)}...` : 'not set');
|
||||
console.log('💡 نکته: اگر URL شما custom domain است، مطمئن شوید که به درستی پیکربندی شده است.');
|
||||
} else {
|
||||
console.log('✅ اتصال به Supabase با موفقیت برقرار شد!');
|
||||
console.log('📋 اطلاعات اتصال:');
|
||||
console.log(' URL:', supabaseUrl);
|
||||
console.log(' Session:', data.session ? 'فعال' : 'غیرفعال (کاربر لاگین نشده)');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error('❌ خطا در تست اتصال Supabase:', errorMessage);
|
||||
console.log('📋 لطفاً بررسی کنید:');
|
||||
console.log(' 1. فایل .env درست تنظیم شده است؟');
|
||||
console.log(' 2. URL و Key صحیح هستند؟');
|
||||
console.log(' 3. اینترنت متصل است؟');
|
||||
console.log(' 4. اگر از custom domain استفاده میکنید، مطمئن شوید که CORS و تنظیمات درست است.');
|
||||
}
|
||||
}
|
||||
|
||||
// Database types (extend as needed)
|
||||
export type Profile = {
|
||||
id: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
full_name?: string;
|
||||
avatar_url?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type Request = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
user?: {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
full_name?: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 48%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Font - فونت سفارشی */
|
||||
@font-face {
|
||||
font-family: 'IRAN Rounded';
|
||||
src: url('/fonts/IRAN Rounded.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'IRAN Rounded', 'Tahoma', 'Arial', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* RTL Support */
|
||||
[dir='rtl'] {
|
||||
direction: rtl;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
[dir='ltr'] {
|
||||
direction: ltr;
|
||||
text-align: left;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Application constants
|
||||
export const APP_NAME = 'PWA React App';
|
||||
export const APP_DESCRIPTION = 'Production-ready Progressive Web App';
|
||||
|
||||
// Route paths
|
||||
export const ROUTES = {
|
||||
HOME: '/',
|
||||
ACCOUNT: '/account',
|
||||
REQUEST: '/request',
|
||||
CHAT: '/chat',
|
||||
LOGIN: '/login',
|
||||
SIGNUP: '/signup'
|
||||
} as const;
|
||||
|
||||
// Request statuses
|
||||
export const REQUEST_STATUS = {
|
||||
PENDING: 'pending',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
COMPLETED: 'completed'
|
||||
} as const;
|
||||
|
||||
// Toast durations
|
||||
export const TOAST_DURATION = {
|
||||
SHORT: 3000,
|
||||
MEDIUM: 5000,
|
||||
LONG: 7000
|
||||
} as const;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SUPABASE_URL: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user