Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4ed96dc9 | ||
|
|
a9bdd1d0a6 |
918
README.md
918
README.md
@@ -1,912 +1,70 @@
|
|||||||
# LOAF Membership Platform - Frontend
|
# Getting Started with Create React App
|
||||||
|
|
||||||
React 19-based frontend application for the LOAF (LGBT Organization and Friends) membership management platform.
|
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||||
|
|
||||||
## Table of Contents
|
## Available Scripts
|
||||||
|
|
||||||
- [Setup & Installation](#setup--installation)
|
In the project directory, you can run:
|
||||||
- [Architecture & Code Structure](#architecture--code-structure)
|
|
||||||
- [Design System](#design-system)
|
|
||||||
- [Deployment Guide](#deployment-guide)
|
|
||||||
- [Troubleshooting](#troubleshooting)
|
|
||||||
|
|
||||||
---
|
### `npm start`
|
||||||
|
|
||||||
## Setup & Installation
|
Runs the app in the development mode.\
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||||
|
|
||||||
### Prerequisites
|
The page will reload when you make changes.\
|
||||||
|
You may also see any lint errors in the console.
|
||||||
|
|
||||||
- **Node.js**: 18.0 or higher
|
### `npm test`
|
||||||
- **Yarn**: 1.22+ (or npm 8+)
|
|
||||||
- **Backend API**: Running on http://localhost:8000
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
Launches the test runner in the interactive watch mode.\
|
||||||
|
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||||
|
|
||||||
```bash
|
### `npm run build`
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# Using Yarn (recommended)
|
Builds the app for production to the `build` folder.\
|
||||||
yarn install
|
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||||
|
|
||||||
# Or using npm
|
The build is minified and the filenames include the hashes.\
|
||||||
npm install
|
Your app is ready to be deployed!
|
||||||
```
|
|
||||||
|
|
||||||
**Key Dependencies:**
|
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||||
- `react@19.0.0` - UI library
|
|
||||||
- `react-router-dom@7.5.1` - Routing
|
|
||||||
- `axios@1.8.4` - HTTP client
|
|
||||||
- `react-hook-form@7.56.2` - Form handling
|
|
||||||
- `zod@3.24.4` - Schema validation
|
|
||||||
- `@radix-ui/*` - UI components (45+ components)
|
|
||||||
- `tailwindcss@3.4.17` - CSS framework
|
|
||||||
- `lucide-react@0.507.0` - Icons
|
|
||||||
- `sonner@1.7.4` - Toast notifications
|
|
||||||
|
|
||||||
### 2. Environment Configuration
|
|
||||||
|
|
||||||
Create `.env` file in the frontend directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Backend API URL
|
|
||||||
REACT_APP_BACKEND_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# Optional: Analytics, Sentry, etc.
|
|
||||||
# REACT_APP_SENTRY_DSN=your-sentry-dsn
|
|
||||||
# REACT_APP_GA_TRACKING_ID=UA-XXXXXXXXX-X
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:**
|
|
||||||
- All environment variables must start with `REACT_APP_`
|
|
||||||
- Restart development server after changing `.env`
|
|
||||||
|
|
||||||
### 3. Start Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start development server
|
|
||||||
yarn start
|
|
||||||
|
|
||||||
# Or with npm
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
**Development server will be available at:**
|
|
||||||
- Frontend: http://localhost:3000
|
|
||||||
- Auto-reloads on file changes
|
|
||||||
|
|
||||||
### 4. Build for Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create production build
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
# Or with npm
|
|
||||||
npm build
|
|
||||||
```
|
|
||||||
|
|
||||||
Build output will be in `/build` directory.
|
|
||||||
|
|
||||||
### 5. Run Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run tests in watch mode
|
|
||||||
yarn test
|
|
||||||
|
|
||||||
# Run tests with coverage
|
|
||||||
yarn test --coverage
|
|
||||||
|
|
||||||
# Or with npm
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture & Code Structure
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
frontend/
|
|
||||||
├── public/ # Static assets
|
|
||||||
│ ├── loaf-logo.png # LOAF logo for admin sidebar
|
|
||||||
│ ├── hero-loaf.png # Landing page hero image
|
|
||||||
│ └── index.html # HTML template
|
|
||||||
├── src/
|
|
||||||
│ ├── pages/ # Page components (20+ pages)
|
|
||||||
│ │ ├── Landing.js # Public landing page
|
|
||||||
│ │ ├── Register.js # 4-step registration (388 lines)
|
|
||||||
│ │ ├── Login.js # Authentication
|
|
||||||
│ │ ├── Dashboard.js # Member dashboard
|
|
||||||
│ │ ├── Profile.js # User profile with photo upload
|
|
||||||
│ │ ├── Events.js # Event listing
|
|
||||||
│ │ ├── EventDetails.js # Event details + RSVP
|
|
||||||
│ │ └── admin/ # Admin pages
|
|
||||||
│ │ ├── AdminDashboard.js
|
|
||||||
│ │ ├── AdminUsers.js
|
|
||||||
│ │ ├── AdminValidations.js # Approve/reject workflow
|
|
||||||
│ │ ├── AdminEvents.js
|
|
||||||
│ │ ├── AdminSubscriptions.js # With CSV export
|
|
||||||
│ │ └── AdminDonations.js # Donation tracking
|
|
||||||
│ ├── components/ # Reusable components
|
|
||||||
│ │ ├── Navbar.js # Main navigation
|
|
||||||
│ │ ├── AdminSidebar.js # Admin sidebar with logo
|
|
||||||
│ │ ├── RejectionDialog.js # Rejection workflow dialog
|
|
||||||
│ │ └── ui/ # 45+ Radix UI components
|
|
||||||
│ │ ├── button.js
|
|
||||||
│ │ ├── dialog.js
|
|
||||||
│ │ ├── input.js
|
|
||||||
│ │ ├── select.js
|
|
||||||
│ │ └── ... (40+ more)
|
|
||||||
│ ├── context/
|
|
||||||
│ │ └── AuthContext.js # Global auth state
|
|
||||||
│ ├── hooks/
|
|
||||||
│ │ └── use-toast.js # Toast notification hook
|
|
||||||
│ ├── utils/
|
|
||||||
│ │ └── api.js # Axios instance with JWT interceptor
|
|
||||||
│ ├── App.js # Main routing setup
|
|
||||||
│ ├── index.js # React entry point
|
|
||||||
│ └── index.css # Global styles + Tailwind
|
|
||||||
├── craco.config.js # Craco configuration
|
|
||||||
├── tailwind.config.js # Tailwind CSS configuration
|
|
||||||
├── package.json # Dependencies
|
|
||||||
└── .env # Environment variables (not in git)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Core Technologies
|
|
||||||
|
|
||||||
| Technology | Purpose | Version |
|
|
||||||
|------------|---------|---------|
|
|
||||||
| React | UI library | 19.0.0 |
|
|
||||||
| React Router | Client-side routing | 7.5.1 |
|
|
||||||
| Axios | HTTP requests | 1.8.4 |
|
|
||||||
| React Hook Form | Form handling | 7.56.2 |
|
|
||||||
| Zod | Schema validation | 3.24.4 |
|
|
||||||
| Tailwind CSS | CSS framework | 3.4.17 |
|
|
||||||
| Radix UI | Component library | Latest |
|
|
||||||
| Lucide React | Icons | 0.507.0 |
|
|
||||||
| Sonner | Toast notifications | 1.7.4 |
|
|
||||||
|
|
||||||
### Key Features
|
|
||||||
|
|
||||||
#### Authentication System
|
|
||||||
|
|
||||||
**Global Auth Context** (`src/context/AuthContext.js`):
|
|
||||||
- JWT token storage in localStorage
|
|
||||||
- Automatic token injection via Axios interceptor
|
|
||||||
- User state management
|
|
||||||
- Protected route wrapper
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```jsx
|
|
||||||
import { useAuth } from '../context/AuthContext';
|
|
||||||
|
|
||||||
function MyComponent() {
|
|
||||||
const { user, login, logout, isAuthenticated } = useAuth();
|
|
||||||
|
|
||||||
// user contains: id, email, first_name, last_name, role, status
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Protected Routes
|
|
||||||
|
|
||||||
**PrivateRoute Wrapper:**
|
|
||||||
```jsx
|
|
||||||
<Route path="/dashboard" element={
|
|
||||||
<PrivateRoute>
|
|
||||||
<Dashboard />
|
|
||||||
</PrivateRoute>
|
|
||||||
} />
|
|
||||||
|
|
||||||
// Admin-only route
|
|
||||||
<Route path="/admin" element={
|
|
||||||
<PrivateRoute adminOnly>
|
|
||||||
<AdminDashboard />
|
|
||||||
</PrivateRoute>
|
|
||||||
} />
|
|
||||||
```
|
|
||||||
|
|
||||||
#### API Integration
|
|
||||||
|
|
||||||
**Axios Instance** (`src/utils/api.js`):
|
|
||||||
- Automatic JWT token injection
|
|
||||||
- Request/response interceptors
|
|
||||||
- Error handling
|
|
||||||
- Base URL configuration
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```jsx
|
|
||||||
import api from '../utils/api';
|
|
||||||
|
|
||||||
// GET request
|
|
||||||
const response = await api.get('/members/profile');
|
|
||||||
|
|
||||||
// POST request with data
|
|
||||||
const response = await api.post('/admin/users/123/reject', {
|
|
||||||
reason: 'Incomplete application'
|
|
||||||
});
|
|
||||||
|
|
||||||
// File upload
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
const response = await api.post('/members/profile/upload-photo', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Form Handling
|
|
||||||
|
|
||||||
**React Hook Form + Zod Pattern:**
|
|
||||||
```jsx
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
email: z.string().email('Invalid email address'),
|
|
||||||
password: z.string().min(8, 'Password must be at least 8 characters')
|
|
||||||
});
|
|
||||||
|
|
||||||
function LoginForm() {
|
|
||||||
const { register, handleSubmit, formState: { errors } } = useForm({
|
|
||||||
resolver: zodResolver(schema)
|
|
||||||
});
|
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
|
||||||
try {
|
|
||||||
await api.post('/auth/login', data);
|
|
||||||
toast.success('Login successful!');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.response?.data?.detail || 'Login failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Input {...register('email')} />
|
|
||||||
{errors.email && <span>{errors.email.message}</span>}
|
|
||||||
{/* ... */}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Toast Notifications
|
|
||||||
|
|
||||||
**Using Sonner:**
|
|
||||||
```jsx
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
// Success
|
|
||||||
toast.success('Profile updated successfully!');
|
|
||||||
|
|
||||||
// Error
|
|
||||||
toast.error('Failed to upload photo');
|
|
||||||
|
|
||||||
// Custom
|
|
||||||
toast('Processing...', {
|
|
||||||
description: 'Please wait while we process your request'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Page Components
|
|
||||||
|
|
||||||
#### Public Pages
|
|
||||||
|
|
||||||
**Landing.js**
|
|
||||||
- Hero section with LOAF branding
|
|
||||||
- Feature highlights
|
|
||||||
- Call-to-action buttons
|
|
||||||
|
|
||||||
**Register.js** (388 lines)
|
|
||||||
- 4-step registration wizard:
|
|
||||||
1. Basic Info (email, password, name)
|
|
||||||
2. Personal Details (phone, address, DOB)
|
|
||||||
3. Partner Information (optional)
|
|
||||||
4. Lead Sources & Referral
|
|
||||||
- Form validation with Zod
|
|
||||||
- Progress indicator
|
|
||||||
- Email verification trigger
|
|
||||||
|
|
||||||
**Login.js**
|
|
||||||
- Email/password authentication
|
|
||||||
- JWT token storage
|
|
||||||
- Remember me functionality
|
|
||||||
- Redirect to dashboard on success
|
|
||||||
|
|
||||||
#### Member Pages
|
|
||||||
|
|
||||||
**Dashboard.js**
|
|
||||||
- Welcome message with user name
|
|
||||||
- Upcoming events preview
|
|
||||||
- Membership status card
|
|
||||||
- Quick action buttons
|
|
||||||
|
|
||||||
**Profile.js**
|
|
||||||
- Profile photo upload with Avatar component
|
|
||||||
- File validation (image types, 50MB max)
|
|
||||||
- Personal information editing
|
|
||||||
- Partner information
|
|
||||||
- Save changes with API update
|
|
||||||
|
|
||||||
**Events.js**
|
|
||||||
- Event listing with filters
|
|
||||||
- Search functionality
|
|
||||||
- Upcoming/past events toggle
|
|
||||||
- Event cards with cover images
|
|
||||||
|
|
||||||
**EventDetails.js**
|
|
||||||
- Full event information
|
|
||||||
- RSVP form (Yes/No/Maybe)
|
|
||||||
- Attendance confirmation
|
|
||||||
- Google Maps integration (optional)
|
|
||||||
|
|
||||||
#### Admin Pages
|
|
||||||
|
|
||||||
**AdminDashboard.js**
|
|
||||||
- Statistics overview
|
|
||||||
- Pending validations count
|
|
||||||
- Recent activity feed
|
|
||||||
- Quick links to management pages
|
|
||||||
|
|
||||||
**AdminUsers.js**
|
|
||||||
- User listing with search/filters
|
|
||||||
- Status badges
|
|
||||||
- Bulk operations
|
|
||||||
- CSV export
|
|
||||||
|
|
||||||
**AdminValidations.js**
|
|
||||||
- Pending user applications
|
|
||||||
- Approve button
|
|
||||||
- **Reject button with RejectionDialog**
|
|
||||||
- Validation workflow management
|
|
||||||
|
|
||||||
**AdminSubscriptions.js**
|
|
||||||
- Subscription listing
|
|
||||||
- Status filters (active, cancelled, expired)
|
|
||||||
- **CSV export dropdown** (Export All / Export Current View)
|
|
||||||
- Edit subscription dialog
|
|
||||||
|
|
||||||
**AdminDonations.js** (400+ lines)
|
|
||||||
- 4 stats cards: Total, Member, Public, Total Amount
|
|
||||||
- Filters: type, status, date range, search
|
|
||||||
- Responsive table with mobile cards
|
|
||||||
- **CSV export functionality**
|
|
||||||
|
|
||||||
**AdminEvents.js**
|
|
||||||
- Event management interface
|
|
||||||
- Create/edit events
|
|
||||||
- Publish/unpublish toggle
|
|
||||||
- Cover image upload
|
|
||||||
- RSVP tracking
|
|
||||||
- Attendance marking
|
|
||||||
|
|
||||||
### Component Library (Radix UI)
|
|
||||||
|
|
||||||
**45+ UI Components** in `src/components/ui/`:
|
|
||||||
|
|
||||||
**Form Components:**
|
|
||||||
- Button, Input, Textarea, Checkbox, Radio
|
|
||||||
- Select, Label, Form
|
|
||||||
|
|
||||||
**Layout Components:**
|
|
||||||
- Card, Dialog, Sheet (Drawer), Popover
|
|
||||||
- Dropdown Menu, Tabs, Accordion
|
|
||||||
|
|
||||||
**Feedback Components:**
|
|
||||||
- Alert, Badge, Toast (Sonner)
|
|
||||||
- Progress, Skeleton, Spinner
|
|
||||||
|
|
||||||
**Navigation:**
|
|
||||||
- Navigation Menu, Breadcrumb, Pagination
|
|
||||||
|
|
||||||
**Usage Example:**
|
|
||||||
```jsx
|
|
||||||
import { Button } from './components/ui/button';
|
|
||||||
import { Dialog, DialogContent, DialogTitle } from './components/ui/dialog';
|
|
||||||
import { Select, SelectTrigger, SelectContent, SelectItem } from './components/ui/select';
|
|
||||||
|
|
||||||
<Button className="bg-[#664fa3] text-white">
|
|
||||||
Click me
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Admin Sidebar Features
|
|
||||||
|
|
||||||
**Logo Integration:**
|
|
||||||
- LOAF logo in header
|
|
||||||
- Shows logo + "Admin" text when expanded
|
|
||||||
- Logo only when collapsed
|
|
||||||
- Smooth transition animations
|
|
||||||
|
|
||||||
**Navigation Groups:**
|
|
||||||
- **Dashboard** (standalone)
|
|
||||||
- **MEMBERSHIP** - Staff, Members, Validations
|
|
||||||
- **FINANCIALS** - Plans, Subscriptions, Donations
|
|
||||||
- **EVENTS & MEDIA** - Events, Gallery
|
|
||||||
- **DOCUMENTATION** - Newsletters, Financial Reports, Bylaws
|
|
||||||
- **Permissions** (Superadmin only)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design System
|
|
||||||
|
|
||||||
### Color Palette
|
|
||||||
|
|
||||||
**LOAF Brand Colors:**
|
|
||||||
```css
|
|
||||||
--primary: #422268 /* Deep Purple - Primary brand */
|
|
||||||
--secondary: #664fa3 /* Light Purple - Secondary elements */
|
|
||||||
--accent: #ff9e77 /* Coral - Accents and highlights */
|
|
||||||
--muted: #ddd8eb /* Light Purple Gray - Borders */
|
|
||||||
--background: #f9f5ff /* Very Light Purple - Backgrounds */
|
|
||||||
--foreground: #422268 /* Text color */
|
|
||||||
```
|
|
||||||
|
|
||||||
**Usage in Tailwind:**
|
|
||||||
```jsx
|
|
||||||
<div className="bg-[#422268] text-white">
|
|
||||||
<h1 className="text-[#ff9e77]">Accent Text</h1>
|
|
||||||
<p className="text-[#664fa3]">Secondary Text</p>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
**Font Families:**
|
|
||||||
- **Headings**: 'Inter', sans-serif
|
|
||||||
- **Body**: 'Nunito Sans', sans-serif
|
|
||||||
- **Code**: 'Fira Code', monospace (if needed)
|
|
||||||
|
|
||||||
**Font Sizes:**
|
|
||||||
```css
|
|
||||||
text-xs → 0.75rem (12px)
|
|
||||||
text-sm → 0.875rem (14px)
|
|
||||||
text-base → 1rem (16px)
|
|
||||||
text-lg → 1.125rem (18px)
|
|
||||||
text-xl → 1.25rem (20px)
|
|
||||||
text-2xl → 1.5rem (24px)
|
|
||||||
text-3xl → 1.875rem (30px)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```jsx
|
|
||||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Page Title
|
|
||||||
</h1>
|
|
||||||
<p className="text-base text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Body text
|
|
||||||
</p>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Spacing System
|
|
||||||
|
|
||||||
**Tailwind Spacing Scale:**
|
|
||||||
```css
|
|
||||||
p-2 → 0.5rem (8px)
|
|
||||||
p-4 → 1rem (16px)
|
|
||||||
p-6 → 1.5rem (24px)
|
|
||||||
p-8 → 2rem (32px)
|
|
||||||
|
|
||||||
gap-2, gap-4, gap-6, gap-8 (same scale for flex/grid gaps)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Styling Patterns
|
|
||||||
|
|
||||||
**Cards:**
|
|
||||||
```jsx
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
{/* Content */}
|
|
||||||
</Card>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Buttons:**
|
|
||||||
```jsx
|
|
||||||
// Primary
|
|
||||||
<Button className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full px-6 py-3">
|
|
||||||
Primary Action
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// Secondary
|
|
||||||
<Button variant="outline" className="border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9] rounded-full">
|
|
||||||
Secondary Action
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
// Destructive
|
|
||||||
<Button className="bg-red-600 text-white hover:bg-red-700 rounded-full">
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Form Inputs:**
|
|
||||||
```jsx
|
|
||||||
<Input className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]" />
|
|
||||||
<Textarea className="rounded-xl border-2 border-[#ddd8eb] min-h-[120px]" />
|
|
||||||
<Select>
|
|
||||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
|
||||||
<SelectValue placeholder="Select..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
</Select>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Badges:**
|
|
||||||
```jsx
|
|
||||||
// Status badges
|
|
||||||
<Badge className="bg-green-100 text-green-800">Active</Badge>
|
|
||||||
<Badge className="bg-yellow-100 text-yellow-800">Pending</Badge>
|
|
||||||
<Badge className="bg-red-100 text-red-800">Rejected</Badge>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Icons (Lucide React)
|
|
||||||
|
|
||||||
**Common Icons:**
|
|
||||||
```jsx
|
|
||||||
import {
|
|
||||||
User, Mail, Phone, MapPin, // User info
|
|
||||||
Calendar, Clock, CheckCircle, // Status
|
|
||||||
Edit, Trash2, X, Check, // Actions
|
|
||||||
Search, Filter, Download, // Utilities
|
|
||||||
Heart, DollarSign, CreditCard, // Financial
|
|
||||||
AlertTriangle, Info, HelpCircle // Alerts
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
<User className="h-5 w-5 text-[#664fa3]" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### Responsive Design
|
|
||||||
|
|
||||||
**Breakpoints:**
|
|
||||||
```css
|
|
||||||
sm: 640px → @media (min-width: 640px)
|
|
||||||
md: 768px → @media (min-width: 768px)
|
|
||||||
lg: 1024px → @media (min-width: 1024px)
|
|
||||||
xl: 1280px → @media (min-width: 1280px)
|
|
||||||
2xl: 1536px → @media (min-width: 1536px)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mobile-First Approach:**
|
|
||||||
```jsx
|
|
||||||
<div className="flex flex-col md:flex-row gap-4">
|
|
||||||
{/* Stacks vertically on mobile, horizontal on tablet+ */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden md:block">
|
|
||||||
{/* Only shows on tablet+ */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="md:hidden">
|
|
||||||
{/* Only shows on mobile */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Animations
|
|
||||||
|
|
||||||
**Tailwind Transitions:**
|
|
||||||
```jsx
|
|
||||||
<Button className="transition-all duration-200 hover:scale-105">
|
|
||||||
Hover me
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="animate-fade-in">
|
|
||||||
{/* Fades in on mount */}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment Guide
|
|
||||||
|
|
||||||
### Production Build
|
|
||||||
|
|
||||||
#### 1. Environment Variables
|
|
||||||
|
|
||||||
Create `.env.production`:
|
|
||||||
```bash
|
|
||||||
REACT_APP_BACKEND_URL=https://api.loaf.org
|
|
||||||
REACT_APP_SENTRY_DSN=your-production-sentry-dsn
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Build Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# Create production build
|
|
||||||
yarn build
|
|
||||||
```
|
|
||||||
|
|
||||||
Build output will be in `/build` directory.
|
|
||||||
|
|
||||||
#### 3. Deployment Options
|
|
||||||
|
|
||||||
### Option A: Netlify (Recommended)
|
|
||||||
|
|
||||||
**Via Netlify CLI:**
|
|
||||||
```bash
|
|
||||||
# Install Netlify CLI
|
|
||||||
npm install -g netlify-cli
|
|
||||||
|
|
||||||
# Login
|
|
||||||
netlify login
|
|
||||||
|
|
||||||
# Deploy
|
|
||||||
netlify deploy --prod --dir=build
|
|
||||||
```
|
|
||||||
|
|
||||||
**Via Git Integration:**
|
|
||||||
1. Push code to GitHub/GitLab
|
|
||||||
2. Connect repository in Netlify dashboard
|
|
||||||
3. Configure:
|
|
||||||
- Build command: `yarn build`
|
|
||||||
- Publish directory: `build`
|
|
||||||
- Environment variables (from .env.production)
|
|
||||||
4. Deploy automatically on push
|
|
||||||
|
|
||||||
**Configure Redirects** (`public/_redirects`):
|
|
||||||
```
|
|
||||||
/* /index.html 200
|
|
||||||
```
|
|
||||||
This enables client-side routing.
|
|
||||||
|
|
||||||
### Option B: Vercel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install Vercel CLI
|
|
||||||
npm install -g vercel
|
|
||||||
|
|
||||||
# Deploy
|
|
||||||
vercel --prod
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configure** (`vercel.json`):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rewrites": [
|
|
||||||
{ "source": "/(.*)", "destination": "/index.html" }
|
|
||||||
],
|
|
||||||
"env": {
|
|
||||||
"REACT_APP_BACKEND_URL": "https://api.loaf.org"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option C: Traditional Web Server (Nginx)
|
|
||||||
|
|
||||||
**1. Copy build files to server:**
|
|
||||||
```bash
|
|
||||||
scp -r build/* user@server:/var/www/loaf-frontend/
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Configure Nginx** (`/etc/nginx/sites-available/loaf-frontend`):
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name app.loaf.org;
|
|
||||||
root /var/www/loaf-frontend;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache static assets
|
|
||||||
location /static/ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# Gzip compression
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Enable site and restart:**
|
|
||||||
```bash
|
|
||||||
sudo ln -s /etc/nginx/sites-available/loaf-frontend /etc/nginx/sites-enabled/
|
|
||||||
sudo nginx -t
|
|
||||||
sudo systemctl restart nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
**4. SSL Certificate:**
|
|
||||||
```bash
|
|
||||||
sudo certbot --nginx -d app.loaf.org
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option D: AWS S3 + CloudFront
|
|
||||||
|
|
||||||
**1. Create S3 bucket:**
|
|
||||||
```bash
|
|
||||||
aws s3 mb s3://loaf-frontend
|
|
||||||
aws s3 sync build/ s3://loaf-frontend --delete
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Configure bucket for static hosting:**
|
|
||||||
```bash
|
|
||||||
aws s3 website s3://loaf-frontend --index-document index.html --error-document index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Create CloudFront distribution** pointing to S3 bucket with custom error pages (all errors → /index.html).
|
|
||||||
|
|
||||||
### Performance Optimization
|
|
||||||
|
|
||||||
**1. Code Splitting:**
|
|
||||||
```jsx
|
|
||||||
// Lazy load routes
|
|
||||||
import { lazy, Suspense } from 'react';
|
|
||||||
|
|
||||||
const AdminDonations = lazy(() => import('./pages/admin/AdminDonations'));
|
|
||||||
|
|
||||||
<Route path="/admin/donations" element={
|
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
|
||||||
<AdminDonations />
|
|
||||||
</Suspense>
|
|
||||||
} />
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Image Optimization:**
|
|
||||||
- Use WebP format when possible
|
|
||||||
- Compress images before upload
|
|
||||||
- Use lazy loading: `loading="lazy"`
|
|
||||||
|
|
||||||
**3. Bundle Analysis:**
|
|
||||||
```bash
|
|
||||||
# Install analyzer
|
|
||||||
yarn add --dev webpack-bundle-analyzer
|
|
||||||
|
|
||||||
# Analyze bundle
|
|
||||||
yarn build
|
|
||||||
npx webpack-bundle-analyzer build/static/js/*.js
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD Pipeline
|
|
||||||
|
|
||||||
**GitHub Actions** (`.github/workflows/deploy.yml`):
|
|
||||||
```yaml
|
|
||||||
name: Deploy Frontend
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
### `npm run eject`
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
- run: yarn install
|
|
||||||
- run: yarn build
|
|
||||||
env:
|
|
||||||
REACT_APP_BACKEND_URL: ${{ secrets.BACKEND_URL }}
|
|
||||||
- uses: netlify/actions/cli@master
|
|
||||||
with:
|
|
||||||
args: deploy --dir=build --prod
|
|
||||||
env:
|
|
||||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_TOKEN }}
|
|
||||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
#### 1. Module Not Found Errors
|
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||||
|
|
||||||
**Error:** `Module not found: Can't resolve 'package-name'`
|
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||||
|
|
||||||
**Solution:**
|
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||||
```bash
|
|
||||||
# Clear node_modules and reinstall
|
|
||||||
rm -rf node_modules yarn.lock
|
|
||||||
yarn install
|
|
||||||
|
|
||||||
# Or with npm
|
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||||
rm -rf node_modules package-lock.json
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. CORS Errors
|
## Learn More
|
||||||
|
|
||||||
**Error:** `Access to XMLHttpRequest blocked by CORS policy`
|
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||||
|
|
||||||
**Solution:**
|
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||||
- Backend must include frontend URL in CORS_ORIGINS
|
|
||||||
- Check REACT_APP_BACKEND_URL is correct
|
|
||||||
- Backend: `CORS_ORIGINS=http://localhost:3000,https://app.loaf.org`
|
|
||||||
|
|
||||||
#### 3. 401 Unauthorized
|
### Code Splitting
|
||||||
|
|
||||||
**Error:** API returns 401 after some time
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||||
|
|
||||||
**Solution:**
|
### Analyzing the Bundle Size
|
||||||
- JWT token expired (default 30 minutes)
|
|
||||||
- User needs to log in again
|
|
||||||
- Check token is being sent in Authorization header
|
|
||||||
|
|
||||||
#### 4. Environment Variables Not Working
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||||
|
|
||||||
**Error:** `process.env.REACT_APP_BACKEND_URL is undefined`
|
### Making a Progressive Web App
|
||||||
|
|
||||||
**Solution:**
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||||
- Ensure variable name starts with `REACT_APP_`
|
|
||||||
- Restart development server after changing .env
|
|
||||||
- Don't commit .env to git (use .env.example)
|
|
||||||
|
|
||||||
#### 5. Build Fails
|
### Advanced Configuration
|
||||||
|
|
||||||
**Error:** `npm run build` fails with memory error
|
|
||||||
|
|
||||||
**Solution:**
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||||
```bash
|
|
||||||
# Increase Node memory limit
|
|
||||||
NODE_OPTIONS=--max_old_space_size=4096 yarn build
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 6. Routing Not Working in Production
|
### Deployment
|
||||||
|
|
||||||
**Error:** Refresh on /dashboard returns 404
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||||
|
|
||||||
**Solution:**
|
### `npm run build` fails to minify
|
||||||
- Configure server to redirect all routes to index.html
|
|
||||||
- Netlify: Add `_redirects` file
|
|
||||||
- Nginx: Use `try_files $uri /index.html`
|
|
||||||
- Vercel: Add vercel.json with rewrites
|
|
||||||
|
|
||||||
#### 7. Images Not Loading
|
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||||
|
|
||||||
**Error:** Profile photos return 404
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Check R2_PUBLIC_URL is correct in backend .env
|
|
||||||
- Verify Cloudflare R2 bucket is public
|
|
||||||
- Check CORS settings in R2 bucket
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
// Add to any component for debugging
|
|
||||||
console.log('Component rendered', { user, props });
|
|
||||||
|
|
||||||
// Check API responses
|
|
||||||
api.interceptors.response.use(
|
|
||||||
response => {
|
|
||||||
console.log('API Response:', response);
|
|
||||||
return response;
|
|
||||||
},
|
|
||||||
error => {
|
|
||||||
console.error('API Error:', error.response);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Getting Help
|
|
||||||
|
|
||||||
- **Project Context**: See `CLAUDE.md` and `PRD.md`
|
|
||||||
- **API Docs**: http://localhost:8000/docs (backend Swagger)
|
|
||||||
- **React Docs**: https://react.dev/
|
|
||||||
- **Tailwind CSS**: https://tailwindcss.com/docs
|
|
||||||
- **Radix UI**: https://www.radix-ui.com/primitives
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- **React Documentation**: https://react.dev/
|
|
||||||
- **React Router**: https://reactrouter.com/
|
|
||||||
- **Tailwind CSS**: https://tailwindcss.com/
|
|
||||||
- **Radix UI**: https://www.radix-ui.com/
|
|
||||||
- **React Hook Form**: https://react-hook-form.com/
|
|
||||||
- **Zod**: https://zod.dev/
|
|
||||||
- **Lucide Icons**: https://lucide.dev/
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated**: December 18, 2024
|
|
||||||
**Version**: 1.0.0
|
|
||||||
**Maintainer**: LOAF Development Team
|
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import AdminEvents from './pages/admin/AdminEvents';
|
|||||||
import AdminValidations from './pages/admin/AdminValidations';
|
import AdminValidations from './pages/admin/AdminValidations';
|
||||||
import AdminPlans from './pages/admin/AdminPlans';
|
import AdminPlans from './pages/admin/AdminPlans';
|
||||||
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
||||||
import AdminDonations from './pages/admin/AdminDonations';
|
|
||||||
import AdminLayout from './layouts/AdminLayout';
|
import AdminLayout from './layouts/AdminLayout';
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||||
import MemberRoute from './components/MemberRoute';
|
import MemberRoute from './components/MemberRoute';
|
||||||
@@ -232,13 +231,6 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/admin/donations" element={
|
|
||||||
<PrivateRoute adminOnly>
|
|
||||||
<AdminLayout>
|
|
||||||
<AdminDonations />
|
|
||||||
</AdminLayout>
|
|
||||||
</PrivateRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/admin/gallery" element={
|
<Route path="/admin/gallery" element={
|
||||||
<PrivateRoute adminOnly>
|
<PrivateRoute adminOnly>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import {
|
|||||||
DollarSign,
|
DollarSign,
|
||||||
Scale,
|
Scale,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Repeat,
|
Repeat
|
||||||
Heart
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||||
@@ -124,12 +123,6 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
path: '/admin/subscriptions',
|
path: '/admin/subscriptions',
|
||||||
disabled: false
|
disabled: false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Donations',
|
|
||||||
icon: Heart,
|
|
||||||
path: '/admin/donations',
|
|
||||||
disabled: false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Events',
|
name: 'Events',
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
@@ -184,73 +177,6 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
return location.pathname.startsWith(path);
|
return location.pathname.startsWith(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderNavItem = (item) => {
|
|
||||||
if (!item) return null;
|
|
||||||
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={item.name} className="relative group">
|
|
||||||
<Link
|
|
||||||
to={item.disabled ? '#' : item.path}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (item.disabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-3 px-4 py-3 rounded-lg transition-all relative
|
|
||||||
${item.disabled
|
|
||||||
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
|
|
||||||
: active
|
|
||||||
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
|
||||||
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Active border */}
|
|
||||||
{active && !item.disabled && (
|
|
||||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#ff9e77] rounded-r" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<>
|
|
||||||
<span className="flex-1">{item.name}</span>
|
|
||||||
{item.disabled && (
|
|
||||||
<Badge className="bg-[#DDD8EB] text-[#422268] text-xs px-2 py-0.5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{item.badge > 0 && !item.disabled && (
|
|
||||||
<Badge className="bg-[#ff9e77] text-white text-xs px-2 py-0.5">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Badge when collapsed */}
|
|
||||||
{!isOpen && item.badge > 0 && !item.disabled && (
|
|
||||||
<div className="absolute -top-1 -right-1 bg-[#ff9e77] text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
|
||||||
{item.badge}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Tooltip when collapsed */}
|
|
||||||
{!isOpen && (
|
|
||||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
|
||||||
{item.name}
|
|
||||||
{item.badge > 0 && ` (${item.badge})`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
@@ -265,23 +191,14 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
||||||
<div className="flex items-center gap-3">
|
{isOpen && (
|
||||||
<img
|
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
Admin
|
||||||
alt="LOAF Logo"
|
</h2>
|
||||||
className={`object-contain transition-all duration-200 ${
|
)}
|
||||||
isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{isOpen && (
|
|
||||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Admin
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
|
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors ml-auto"
|
||||||
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||||
>
|
>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -295,71 +212,71 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 overflow-y-auto p-4">
|
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{/* Dashboard - Standalone */}
|
{filteredNavItems.map((item) => {
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
const Icon = item.icon;
|
||||||
|
const active = isActive(item.path);
|
||||||
|
|
||||||
{/* MEMBERSHIP Section */}
|
return (
|
||||||
{isOpen && (
|
<div key={item.name} className="relative group">
|
||||||
<div className="px-4 py-2 mt-6">
|
<Link
|
||||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
to={item.disabled ? '#' : item.path}
|
||||||
Membership
|
onClick={(e) => {
|
||||||
</h3>
|
if (item.disabled) {
|
||||||
</div>
|
e.preventDefault();
|
||||||
)}
|
}
|
||||||
<div className="space-y-1">
|
}}
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff'))}
|
className={`
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Members'))}
|
flex items-center gap-3 px-4 py-3 rounded-lg transition-all relative
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
|
${item.disabled
|
||||||
</div>
|
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
|
||||||
|
: active
|
||||||
|
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
||||||
|
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Active border */}
|
||||||
|
{active && !item.disabled && (
|
||||||
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#ff9e77] rounded-r" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* FINANCIALS Section */}
|
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||||
{isOpen && (
|
|
||||||
<div className="px-4 py-2 mt-6">
|
|
||||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
|
||||||
Financials
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Plans'))}
|
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Subscriptions'))}
|
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Donations'))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* EVENTS & MEDIA Section */}
|
{isOpen && (
|
||||||
{isOpen && (
|
<>
|
||||||
<div className="px-4 py-2 mt-6">
|
<span className="flex-1">{item.name}</span>
|
||||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
{item.disabled && (
|
||||||
Events & Media
|
<Badge className="bg-[#DDD8EB] text-[#422268] text-xs px-2 py-0.5">
|
||||||
</h3>
|
Soon
|
||||||
</div>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
{item.badge > 0 && !item.disabled && (
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Events'))}
|
<Badge className="bg-[#ff9e77] text-white text-xs px-2 py-0.5">
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Gallery'))}
|
{item.badge}
|
||||||
</div>
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* DOCUMENTATION Section */}
|
{/* Badge when collapsed */}
|
||||||
{isOpen && (
|
{!isOpen && item.badge > 0 && !item.disabled && (
|
||||||
<div className="px-4 py-2 mt-6">
|
<div className="absolute -top-1 -right-1 bg-[#ff9e77] text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
{item.badge}
|
||||||
Documentation
|
</div>
|
||||||
</h3>
|
)}
|
||||||
</div>
|
</Link>
|
||||||
)}
|
|
||||||
<div className="space-y-1">
|
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Newsletters'))}
|
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Financials'))}
|
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permissions - Superadmin only (no header) */}
|
{/* Tooltip when collapsed */}
|
||||||
{user?.role === 'superadmin' && (
|
{!isOpen && (
|
||||||
<div className="mt-6">
|
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
|
{item.name}
|
||||||
</div>
|
{item.badge > 0 && ` (${item.badge})`}
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ const PublicFooter = () => {
|
|||||||
<>
|
<>
|
||||||
{/* Main Footer */}
|
{/* Main Footer */}
|
||||||
<footer className="bg-[#644c9f] border-t border-[rgba(0,0,0,0.1)] py-8 px-2 lg:px-10 flex items-center justify-between">
|
<footer className="bg-[#644c9f] border-t border-[rgba(0,0,0,0.1)] py-8 px-2 lg:px-10 flex items-center justify-between">
|
||||||
<div className=" flex flex-col md:flex-row gap-14 md:gap-2 lg:gap-40 items-center text-left md:justify-between w-full max-w-7xl mx-auto">
|
<div className=" flex flex-col md:flex-row gap-14 md:gap-2 lg:gap-32 xl:gap-40 items-center justify-center text-left md:justify-between w-full max-w-7xl mx-auto">
|
||||||
<div className="w-40 sm:w-40 md:w-48 lg:w-[180px] flex-shrink-0">
|
<div className="w-40 sm:w-40 md:w-48 lg:w-[180px] flex-shrink-0">
|
||||||
<img src={loafLogo} alt="LOAF Logo" className="w-full h-auto aspect-square object-contain" />
|
<img src={loafLogo} alt="LOAF Logo" className="w-full h-auto aspect-square object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-col sm:flex-row sm:flex-nowrap gap-8 md:gap-4 lg:gap-20 xl:gap-28 items-start justify-center w-full lg:w-auto">
|
<nav className="flex flex-col sm:flex-row sm:flex-nowrap gap-8 sm:gap-4 lg:gap-20 xl:gap-28 items-start justify-center w-full lg:w-auto">
|
||||||
|
|
||||||
<div className="md:flex hidden flex-col gap-2 items-start text-left w-full sm:w-auto sm:min-w-[163px]">
|
<div className="md:flex hidden flex-col gap-2 items-start text-left w-full sm:w-auto sm:min-w-[163px]">
|
||||||
<div className="pb-2 lg:pb-4">
|
<div className="pb-2 lg:pb-4">
|
||||||
@@ -32,15 +32,15 @@ const PublicFooter = () => {
|
|||||||
<Link to="/resources" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Resources</Link>
|
<Link to="/resources" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Resources</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 items-start text-left w-full sm:w-auto sm:min-w-[200px] md:min-w-[200px] lg:min-w-[220px]">
|
<div className="flex flex-col gap-2 items-center justify-center md:items-start text-left w-full sm:w-auto sm:min-w-[200px] md:min-w-[200px] lg:min-w-[220px]">
|
||||||
<div className="pb-4 w-full flex justify-center sm:justify-start md:justify-center lg:justify-start">
|
<div className="pb-4 w-full flex justify-center lg:justify-start">
|
||||||
<Link to="/donate" className="block">
|
<Link to="/donate" className="block">
|
||||||
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-full px-12 lg:px-16 py-6 text-lg sm:text-lg font-medium ">
|
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-full px-12 lg:px-16 py-6 text-lg sm:text-lg font-medium ">
|
||||||
Donate
|
Donate
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[#ddd8eb] text-sm sm:text-base font-medium text-left w-full" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-[#ddd8eb] text-sm sm:text-base font-medium text-center md:text-left w-full" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
LOAF is supported by<br />the Hollyfield Foundation
|
LOAF is supported by<br />the Hollyfield Foundation
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -72,125 +72,128 @@ const PublicNavbar = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Top Header - Auth Actions */}
|
{/* Top Header - Auth Actions */}
|
||||||
<header className="bg-gradient-to-r from-[#644c9f] to-[#48286e] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6">
|
<div className='sticky top-0 inset-x-0 z-50'>
|
||||||
<div className='flex gap-4 sm:gap-6'>
|
|
||||||
|
|
||||||
|
<header className="bg-gradient-to-r flex-wrap from-[#644c9f] to-[#48286e] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6">
|
||||||
|
<div className='flex gap-4 sm:gap-6'>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleAuthAction}
|
||||||
|
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{user ? 'Logout' : 'Login'}
|
||||||
|
</button>
|
||||||
|
{!user && (
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Link to="/donate">
|
||||||
|
<Button
|
||||||
|
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[50px] py-[5px] text-[16.5px] font-semibold h-[41px]"
|
||||||
|
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Header - Navigation */}
|
||||||
|
<header className=" bg-[#664fa3] px-[20px] py-2 flex justify-between items-center">
|
||||||
|
<Link to="/">
|
||||||
|
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleAuthAction}
|
onClick={() => setIsMobileMenuOpen(true)}
|
||||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
|
className="lg:hidden p-2 text-white hover:bg-[#48286e] rounded-md transition-colors"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
{user ? 'Logout' : 'Login'}
|
<Menu className="size-14" />
|
||||||
</button>
|
</button>
|
||||||
{!user && (
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<nav className="hidden lg:flex gap-6 items-center">
|
||||||
<Link
|
<Link
|
||||||
to="/register"
|
to="/#welcome"
|
||||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
|
className={getDesktopLinkClasses('/#welcome')}
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
>
|
>
|
||||||
Register
|
Welcome
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
<DropdownMenu>
|
||||||
</div>
|
<DropdownMenuTrigger asChild>
|
||||||
<Link to="/donate">
|
<button
|
||||||
<Button
|
className={`${isAboutActive()
|
||||||
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[50px] py-[5px] text-[16.5px] font-semibold h-[41px]"
|
? "text-[#ff9e77] hover:text-[#ff8c64]"
|
||||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
: "text-white hover:opacity-80"} text-[17.5px] font-medium transition-all flex items-center gap-1 bg-transparent border-none cursor-pointer px-3 py-1 rounded-md`}
|
||||||
>
|
|
||||||
Donate
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main Header - Navigation */}
|
|
||||||
<header className="sticky top-0 inset-x-0 z-40 bg-[#664fa3] px-[20px] py-2 flex justify-between items-center">
|
|
||||||
<Link to="/">
|
|
||||||
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMobileMenuOpen(true)}
|
|
||||||
className="lg:hidden p-2 text-white hover:bg-[#48286e] rounded-md transition-colors"
|
|
||||||
aria-label="Open menu"
|
|
||||||
>
|
|
||||||
<Menu className="size-14" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
|
||||||
<nav className="hidden lg:flex gap-6 items-center">
|
|
||||||
<Link
|
|
||||||
to="/#welcome"
|
|
||||||
className={getDesktopLinkClasses('/#welcome')}
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
||||||
>
|
|
||||||
Welcome
|
|
||||||
</Link>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={`${isAboutActive()
|
|
||||||
? "text-[#ff9e77] hover:text-[#ff8c64]"
|
|
||||||
: "text-white hover:opacity-80"} text-[17.5px] font-medium transition-all flex items-center gap-1 bg-transparent border-none cursor-pointer px-3 py-1 rounded-md`}
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
About Us
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to="/about/history" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
History
|
About Us
|
||||||
</Link>
|
<ChevronDown className="h-4 w-4" />
|
||||||
</DropdownMenuItem>
|
</button>
|
||||||
<DropdownMenuItem asChild>
|
</DropdownMenuTrigger>
|
||||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<DropdownMenuItem asChild>
|
||||||
Mission and Values
|
<Link to="/about/history" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||||
</Link>
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
</DropdownMenuItem>
|
History
|
||||||
<DropdownMenuItem asChild>
|
</Link>
|
||||||
<Link to="/about/board" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
</DropdownMenuItem>
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<DropdownMenuItem asChild>
|
||||||
Board of Directors
|
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||||
</Link>
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
</DropdownMenuItem>
|
Mission and Values
|
||||||
</DropdownMenuContent>
|
</Link>
|
||||||
</DropdownMenu>
|
</DropdownMenuItem>
|
||||||
<Link
|
<DropdownMenuItem asChild>
|
||||||
to={user ? "/dashboard" : "/become-a-member"}
|
<Link to="/about/board" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||||
className={getDesktopLinkClasses(user ? "/dashboard" : "/become-a-member")}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
Board of Directors
|
||||||
>
|
</Link>
|
||||||
{user ? 'Dashboard' : 'Become a Member'}
|
</DropdownMenuItem>
|
||||||
</Link>
|
</DropdownMenuContent>
|
||||||
{!user && (
|
</DropdownMenu>
|
||||||
<Link
|
<Link
|
||||||
to="/login"
|
to={user ? "/dashboard" : "/become-a-member"}
|
||||||
className={getDesktopLinkClasses('/login')}
|
className={getDesktopLinkClasses(user ? "/dashboard" : "/become-a-member")}
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
>
|
>
|
||||||
Members Only
|
{user ? 'Dashboard' : 'Become a Member'}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
{!user && (
|
||||||
<Link
|
<Link
|
||||||
to="/resources"
|
to="/login"
|
||||||
className={getDesktopLinkClasses('/resources')}
|
className={getDesktopLinkClasses('/login')}
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
>
|
>
|
||||||
Resources
|
Members Only
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
)}
|
||||||
to="/contact-us"
|
<Link
|
||||||
className={getDesktopLinkClasses('/contact-us')}
|
to="/resources"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
className={getDesktopLinkClasses('/resources')}
|
||||||
>
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
Contact Us
|
>
|
||||||
</Link>
|
Resources
|
||||||
</nav>
|
</Link>
|
||||||
</header>
|
<Link
|
||||||
|
to="/contact-us"
|
||||||
|
className={getDesktopLinkClasses('/contact-us')}
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Contact Us
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
</div>
|
||||||
{/* Mobile Menu Drawer */}
|
{/* Mobile Menu Drawer */}
|
||||||
{isMobileMenuOpen && (
|
{isMobileMenuOpen && (
|
||||||
<div className="fixed inset-0 z-50 lg:hidden">
|
<div className="fixed inset-0 z-50 lg:hidden">
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from './ui/dialog';
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { Textarea } from './ui/textarea';
|
|
||||||
import { Label } from './ui/label';
|
|
||||||
import { AlertTriangle, X } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function RejectionDialog({ open, onOpenChange, onConfirm, user, loading }) {
|
|
||||||
const [reason, setReason] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!reason.trim()) {
|
|
||||||
setError('Rejection reason is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onConfirm(reason);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
setReason('');
|
|
||||||
setError('');
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
|
||||||
<DialogContent className="sm:max-w-[500px] rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="p-3 bg-red-100 rounded-full">
|
|
||||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Reject Application
|
|
||||||
</DialogTitle>
|
|
||||||
</div>
|
|
||||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
You are about to reject <strong>{user?.first_name} {user?.last_name}</strong>'s membership application.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
<div className="bg-[#f9f5ff] border border-[#ddd8eb] rounded-lg p-4">
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
<strong>Applicant:</strong> {user?.email}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
<strong>Status:</strong> {user?.status}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="reason" className="text-[#422268] font-medium">
|
|
||||||
Rejection Reason <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="reason"
|
|
||||||
value={reason}
|
|
||||||
onChange={(e) => {
|
|
||||||
setReason(e.target.value);
|
|
||||||
setError('');
|
|
||||||
}}
|
|
||||||
placeholder="Please provide a clear reason for rejection. This will be sent to the applicant."
|
|
||||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-red-500 min-h-[120px]"
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-500">{error}</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
The applicant will receive an email with this reason.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClose}
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-6"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-2" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700 rounded-full px-6"
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
|
||||||
{loading ? 'Rejecting...' : 'Confirm Rejection'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
195
src/index.css
195
src/index.css
@@ -1,115 +1,118 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap");
|
||||||
|
|
||||||
|
@import url("https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap");
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family:
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
sans-serif;
|
||||||
sans-serif;
|
-webkit-font-smoothing: antialiased;
|
||||||
-webkit-font-smoothing: antialiased;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family:
|
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 280 47% 27%;
|
--foreground: 280 47% 27%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 280 47% 27%;
|
--card-foreground: 280 47% 27%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 280 47% 27%;
|
--popover-foreground: 280 47% 27%;
|
||||||
--primary: 280 47% 27%;
|
--primary: 280 47% 27%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 268 33% 89%;
|
--secondary: 268 33% 89%;
|
||||||
--secondary-foreground: 280 47% 27%;
|
--secondary-foreground: 280 47% 27%;
|
||||||
--muted: 268 43% 95%;
|
--muted: 268 43% 95%;
|
||||||
--muted-foreground: 268 35% 47%;
|
--muted-foreground: 268 35% 47%;
|
||||||
--accent: 17 100% 73%;
|
--accent: 17 100% 73%;
|
||||||
--accent-foreground: 280 47% 27%;
|
--accent-foreground: 280 47% 27%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 268 33% 89%;
|
--border: 268 33% 89%;
|
||||||
--input: 268 33% 89%;
|
--input: 268 33% 89%;
|
||||||
--ring: 268 35% 47%;
|
--ring: 268 35% 47%;
|
||||||
--chart-1: 268 36% 46%;
|
--chart-1: 268 36% 46%;
|
||||||
--chart-2: 17 100% 73%;
|
--chart-2: 17 100% 73%;
|
||||||
--chart-3: 268 33% 89%;
|
--chart-3: 268 33% 89%;
|
||||||
--chart-4: 280 44% 29%;
|
--chart-4: 280 44% 29%;
|
||||||
--chart-5: 268 35% 47%;
|
--chart-5: 268 35% 47%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
--background: 0 0% 3.9%;
|
--background: 0 0% 3.9%;
|
||||||
--foreground: 0 0% 98%;
|
--foreground: 0 0% 98%;
|
||||||
--card: 0 0% 3.9%;
|
--card: 0 0% 3.9%;
|
||||||
--card-foreground: 0 0% 98%;
|
--card-foreground: 0 0% 98%;
|
||||||
--popover: 0 0% 3.9%;
|
--popover: 0 0% 3.9%;
|
||||||
--popover-foreground: 0 0% 98%;
|
--popover-foreground: 0 0% 98%;
|
||||||
--primary: 0 0% 98%;
|
--primary: 0 0% 98%;
|
||||||
--primary-foreground: 0 0% 9%;
|
--primary-foreground: 0 0% 9%;
|
||||||
--secondary: 0 0% 14.9%;
|
--secondary: 0 0% 14.9%;
|
||||||
--secondary-foreground: 0 0% 98%;
|
--secondary-foreground: 0 0% 98%;
|
||||||
--muted: 0 0% 14.9%;
|
--muted: 0 0% 14.9%;
|
||||||
--muted-foreground: 0 0% 63.9%;
|
--muted-foreground: 0 0% 63.9%;
|
||||||
--accent: 0 0% 14.9%;
|
--accent: 0 0% 14.9%;
|
||||||
--accent-foreground: 0 0% 98%;
|
--accent-foreground: 0 0% 98%;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--destructive: 0 62.8% 30.6%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 0 0% 14.9%;
|
--border: 0 0% 14.9%;
|
||||||
--input: 0 0% 14.9%;
|
--input: 0 0% 14.9%;
|
||||||
--ring: 0 0% 83.1%;
|
--ring: 0 0% 83.1%;
|
||||||
--chart-1: 220 70% 50%;
|
--chart-1: 220 70% 50%;
|
||||||
--chart-2: 160 60% 45%;
|
--chart-2: 160 60% 45%;
|
||||||
--chart-3: 30 80% 55%;
|
--chart-3: 30 80% 55%;
|
||||||
--chart-4: 280 65% 60%;
|
--chart-4: 280 65% 60%;
|
||||||
--chart-5: 340 75% 55%;
|
--chart-5: 340 75% 55%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
[data-debug-wrapper="true"] {
|
[data-debug-wrapper="true"] {
|
||||||
display: contents !important;
|
display: contents !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-debug-wrapper="true"] > * {
|
[data-debug-wrapper="true"] > * {
|
||||||
margin-left: inherit;
|
margin-left: inherit;
|
||||||
margin-right: inherit;
|
margin-right: inherit;
|
||||||
margin-top: inherit;
|
margin-top: inherit;
|
||||||
margin-bottom: inherit;
|
margin-bottom: inherit;
|
||||||
padding-left: inherit;
|
padding-left: inherit;
|
||||||
padding-right: inherit;
|
padding-right: inherit;
|
||||||
padding-top: inherit;
|
padding-top: inherit;
|
||||||
padding-bottom: inherit;
|
padding-bottom: inherit;
|
||||||
column-gap: inherit;
|
column-gap: inherit;
|
||||||
row-gap: inherit;
|
row-gap: inherit;
|
||||||
gap: inherit;
|
gap: inherit;
|
||||||
border-left-width: inherit;
|
border-left-width: inherit;
|
||||||
border-right-width: inherit;
|
border-right-width: inherit;
|
||||||
border-top-width: inherit;
|
border-top-width: inherit;
|
||||||
border-bottom-width: inherit;
|
border-bottom-width: inherit;
|
||||||
border-left-style: inherit;
|
border-left-style: inherit;
|
||||||
border-right-style: inherit;
|
border-right-style: inherit;
|
||||||
border-top-style: inherit;
|
border-top-style: inherit;
|
||||||
border-bottom-style: inherit;
|
border-bottom-style: inherit;
|
||||||
border-left-color: inherit;
|
border-left-color: inherit;
|
||||||
border-right-color: inherit;
|
border-right-color: inherit;
|
||||||
border-top-color: inherit;
|
border-top-color: inherit;
|
||||||
border-bottom-color: inherit;
|
border-bottom-color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,20 @@ const BecomeMember = () => {
|
|||||||
const imgIconAdminFee5 = `${process.env.PUBLIC_URL}/imgIconAdminFee5.png`;
|
const imgIconAdminFee5 = `${process.env.PUBLIC_URL}/imgIconAdminFee5.png`;
|
||||||
const imgShootingStar = `${process.env.PUBLIC_URL}/imgShootingStar.png`;
|
const imgShootingStar = `${process.env.PUBLIC_URL}/imgShootingStar.png`;
|
||||||
|
|
||||||
|
const Arrow = ({ ...props }) => (
|
||||||
|
<div className="flex justify-center mb-2">
|
||||||
|
<ArrowDown className="size-8 text-[#4f378a] font-bold" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 relative">
|
<div className="min-h-screen bg-gray-50 relative">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
{/* Decorative shooting star element */}
|
|
||||||
<div className="hidden lg:block absolute left-[88px] top-[974px] w-[195px] h-[1135px] pointer-events-none opacity-50">
|
|
||||||
<img
|
|
||||||
src={imgShootingStar}
|
|
||||||
alt=""
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<div className="relative bg-gray-50 pt-20 pb-24">
|
<div className="relative bg-gray-50 pt-20 px-6 pb-16">
|
||||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
<div className="max-w-7xl mx-auto text-center">
|
||||||
<h1
|
<h1
|
||||||
className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.96px]"
|
className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.96px]"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
@@ -46,7 +44,7 @@ const BecomeMember = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Annual Administrative Fees Section */}
|
{/* Annual Administrative Fees Section */}
|
||||||
<div className="max-w-[1340px] mx-auto px-6 mb-12 sm:mb-16">
|
<div className="max-w-[1340px] z-10 mx-auto px-6 mb-12 sm:mb-16">
|
||||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
@@ -73,158 +71,159 @@ const BecomeMember = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Membership Process Section */}
|
{/* Membership Process Section */}
|
||||||
<div className="relative bg-gray-50 py-16">
|
<div className="relative bg-gray-50 py-32 bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB] ">
|
||||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
{/* Decorative shooting star element */}
|
||||||
|
<div className="hidden lg:block absolute left-0 top-64 w-[195px] h-[1130px] pointer-events-none opacity-50">
|
||||||
|
<img
|
||||||
|
src={imgShootingStar}
|
||||||
|
alt=""
|
||||||
|
className="w-full h-full z-20 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-7xl mx-auto px-6 mb-24 text-center">
|
||||||
<h2
|
<h2
|
||||||
className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
className="text-2xl sm:text-3xl md:text-[40px] font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
>
|
>
|
||||||
Membership Process
|
Membership Process
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
className="text-base sm:text-lg font-medium text-[#48286e] max-w-2xl mx-auto leading-[1.6]"
|
className="text-base sm:text-lg md:text-xl lg:text-2xl lg:font-semibold font-medium text-[#48286e] max-w-2xl mx-auto leading-[1.6]"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||||
>
|
>
|
||||||
Becoming a member is easy, but for the safety and privacy of our membership, there are a few steps:
|
Becoming a member is easy, but for the safety and privacy of our membership, there are a few steps:
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 1 */}
|
|
||||||
<div className="max-w-[1340px] mx-auto px-6 mb-6 sm:mb-8">
|
{/* Step 1 */}
|
||||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
<div className="max-w-[1340px] mx-auto px-6 mb-2 ">
|
||||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||||
<img
|
<div className="w-24 h-24 sm:w-32 z-40 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||||
src={imgIconAdminFee2}
|
<img
|
||||||
alt="Step 1 Icon"
|
src={imgIconAdminFee2}
|
||||||
className="w-full h-full object-contain"
|
alt="Step 1 Icon"
|
||||||
/>
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||||
|
<h3
|
||||||
|
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
>
|
||||||
|
Step 1: Application & Email Confirmation
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||||
|
>
|
||||||
|
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are validated by an admin.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
</div>
|
||||||
<h3
|
|
||||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
{/* Arrow Down Icon */}
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
<Arrow />
|
||||||
>
|
|
||||||
Step 1: Application & Email Confirmation
|
{/* Step 2 */}
|
||||||
</h3>
|
<div className="max-w-[1340px] mx-auto px-6 mb-2">
|
||||||
<p
|
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
<div className="w-24 h-24 z-40 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
<img
|
||||||
>
|
src={imgIconAdminFee3}
|
||||||
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are validated by an admin.
|
alt="Step 2 Icon"
|
||||||
</p>
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||||
|
<h3
|
||||||
|
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
>
|
||||||
|
Step 2: Attend an event and meet us!
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||||
|
>
|
||||||
|
You have 3 months to attend a LOAF event and introduce yourself to a board member. If you do not attend an event within 3 months, you will no longer receive the e-newsletter. (This step can be skipped if you have been referred from a current member and list her on your registration form).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow Down Icon */}
|
||||||
|
<Arrow />
|
||||||
|
{/* Step 3 */}
|
||||||
|
<div className="max-w-[1340px] mx-auto px-6 mb-2">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||||
|
<div className="w-24 h-24 z-40 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={imgIconAdminFee4}
|
||||||
|
alt="Step 3 Icon"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||||
|
<h3
|
||||||
|
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
>
|
||||||
|
Step 3: Login and pay the annual fee
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||||
|
>
|
||||||
|
Once we know that you are indeed you, an admin will validate your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Arrow Down Icon */}
|
||||||
|
<Arrow />
|
||||||
|
|
||||||
|
{/* Step 4 - With Gradient Background */}
|
||||||
|
<div className="max-w-[1340px] mx-auto px-6 mb-2">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
||||||
|
<div className="w-24 h-24 z-40 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={imgIconAdminFee5}
|
||||||
|
alt="Step 4 Icon"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 bg-gradient-to-r from-[#48286e] to-[#664fa3] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||||
|
<h3
|
||||||
|
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-white mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
>
|
||||||
|
Step 4: Welcome to LOAF!
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
className="text-base sm:text-lg font-medium text-white leading-[1.6]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||||
|
>
|
||||||
|
Congratulations! Your application is complete, and you now have access to Members Only content. We hope to see you at future events soon!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow Down Icon */}
|
|
||||||
<div className="flex justify-center mb-8">
|
|
||||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 2 */}
|
|
||||||
<div className="max-w-[1340px] mx-auto px-6 mb-6 sm:mb-8">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
|
||||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={imgIconAdminFee3}
|
|
||||||
alt="Step 2 Icon"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
|
||||||
<h3
|
|
||||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
|
||||||
>
|
|
||||||
Step 2: Attend an event and meet us!
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
|
||||||
>
|
|
||||||
You have 3 months to attend a LOAF event and introduce yourself to a board member. If you do not attend an event within 3 months, you will no longer receive the e-newsletter. (This step can be skipped if you have been referred from a current member and list her on your registration form).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arrow Down Icon */}
|
|
||||||
<div className="flex justify-center mb-8">
|
|
||||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 3 */}
|
|
||||||
<div className="max-w-[1340px] mx-auto px-6 mb-6 sm:mb-8">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
|
||||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={imgIconAdminFee4}
|
|
||||||
alt="Step 3 Icon"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
|
||||||
<h3
|
|
||||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
|
||||||
>
|
|
||||||
Step 3: Login and pay the annual fee
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
|
||||||
>
|
|
||||||
Once we know that you are indeed you, an admin will validate your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arrow Down Icon */}
|
|
||||||
<div className="flex justify-center mb-8">
|
|
||||||
<ArrowDown className="w-8 h-8 text-[#4f378a]" strokeWidth={2} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Step 4 - With Gradient Background */}
|
|
||||||
<div className="max-w-[1340px] mx-auto px-6 mb-12 sm:mb-16">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-5 items-center">
|
|
||||||
<div className="w-24 h-24 sm:w-32 sm:h-32 md:w-[153px] md:h-[138px] flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src={imgIconAdminFee5}
|
|
||||||
alt="Step 4 Icon"
|
|
||||||
className="w-full h-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 bg-gradient-to-r from-[#48286e] to-[#664fa3] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
|
||||||
<h3
|
|
||||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-white mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
|
||||||
>
|
|
||||||
Step 4: Welcome to LOAF!
|
|
||||||
</h3>
|
|
||||||
<p
|
|
||||||
className="text-base sm:text-lg font-medium text-white leading-[1.6]"
|
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
|
||||||
>
|
|
||||||
Congratulations! Your application is complete, and you now have access to Members Only content. We hope to see you at future events soon!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA Section */}
|
||||||
<div className="relative bg-gray-50 py-16">
|
<div className="relative bg-gray-50 py-16 ">
|
||||||
<div className="max-w-7xl mx-auto px-6 text-center">
|
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row items-center justify-center gap-12 text-center">
|
||||||
<h2
|
<h2
|
||||||
className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
className="text-2xl sm:text-3xl md:text-[40px] content-center font-bold text-[#48286e] leading-[1.2] tracking-[-0.8px]"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
>
|
>
|
||||||
Ready to Join Us?
|
Ready to Join Us?
|
||||||
</h2>
|
</h2>
|
||||||
<Link to="/register">
|
<Link to="/register">
|
||||||
<Button
|
<Button
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-[35px] px-6 py-3 sm:px-12 sm:py-5 md:px-16 md:py-6 text-base sm:text-lg font-medium tracking-[-0.09px] h-auto"
|
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-[35px] px-6 py-3 sm:px-12 sm:py-5 md:px-16 md:py-6 text-[19px] sm:text-lg font-medium tracking-[-0.09px] leading-5 h-auto"
|
||||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
>
|
>
|
||||||
Register Now!
|
Register Now!
|
||||||
|
|||||||
@@ -12,24 +12,78 @@ const BoardOfDirectors = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const boardMembers = [
|
const boardMembers = [
|
||||||
'Danita Cole',
|
{ name: 'Danita Cole' },
|
||||||
'Roxanne Cherico',
|
{ name: 'Roxanne Cherico' },
|
||||||
'Lucretia Copeland',
|
{ name: 'Lucretia Copeland' },
|
||||||
'Julie Fischer'
|
{ name: 'Julie Fischer' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const DirectorCards = ({ title, members }) => {
|
||||||
|
return (
|
||||||
|
<section className=" w-full">
|
||||||
|
<div className="mx-auto bg-white rounded-3xl p-10 shadow-lg h-full">
|
||||||
|
{title && (
|
||||||
|
<h2
|
||||||
|
className="text-2xl sm:text-4xl font-bold text-[#48286e] text-center mb-8"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-col-span-1 lg:grid-cols-2 gap-5 w-full">
|
||||||
|
{members.map((member, index) => {
|
||||||
|
const { name, title } =
|
||||||
|
typeof member === "string"
|
||||||
|
? { name: member, title: "" }
|
||||||
|
: member;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={`${name}-${index}`}
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
className="bg-[#eeebf4] text-[#48286e] text-center px-6 py-5 rounded-3xl border border-white/70 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="min-h-16">
|
||||||
|
<p className="text-xl font-bold text-[#48286e]">
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{title && (
|
||||||
|
<p className="text-xl mt-2 font-semibold">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<main className="bg-gradient-to-b from-white to-[#f1eef9] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
<main className="bg-gradient-to-b from-[#f9fafb] to-[#ddd8eb] pt-8 sm:pt-10 md:pt-12">
|
||||||
{/* Hero Section with Contact */}
|
{/* Hero Section */}
|
||||||
<section className="bg-gradient-to-r from-[#664fa3] to-[#48286e] py-8 rounded-2xl mb-12">
|
<section className=" pt-16 pb-4 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||||
<div className="max-w-5xl mx-auto text-center px-8">
|
<div className="max-w-5xl mx-auto text-center px-8">
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h1 className="text-2xl sm:text-3xl md:text-[40px] leading-[1.2] text-[#48286e] font-bold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
LOAF Board of Directors 2025
|
LOAF Board of Directors 2025
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-white text-lg mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/* Contact Info */}
|
||||||
|
<section className="flex justify-center mt-4 mb-8">
|
||||||
|
<div className=" w-full text-center px-8 justify-center bg-gradient-to-r from-[#664fa3] to-[#48286e] max-w-5xl mx-6 flex lg:mx-12 py-5 rounded-3xl sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||||
|
|
||||||
|
<p className="text-white text-xl font-bold " style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
For any questions or inquiries please email us at{' '}
|
For any questions or inquiries please email us at{' '}
|
||||||
<a href="mailto:info@loaftx.org" className="text-[#c5b4e3] underline font-bold hover:text-white transition-colors">
|
<a href="mailto:info@loaftx.org" className="text-[#c5b4e3] underline font-bold hover:text-white transition-colors">
|
||||||
info@loaftx.org
|
info@loaftx.org
|
||||||
@@ -37,74 +91,71 @@ const BoardOfDirectors = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{/* Board Members Section */}
|
||||||
|
<section className=' flex lg:flex-row flex-col gap-10 items-stretch mt-8 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20 pb-12'>
|
||||||
|
{/* Officers Grid */}
|
||||||
|
<DirectorCards title="Officers" members={officers} />
|
||||||
|
|
||||||
{/* Officers Grid */}
|
{/* Board Members Grid */}
|
||||||
<section className="py-12">
|
<DirectorCards title="Board Of Directors" members={boardMembers} />
|
||||||
<div className="max-w-6xl mx-auto">
|
|
||||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Officers
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{officers.map((officer, index) => (
|
|
||||||
<Card key={index} className="bg-[#eeebf4] p-6 text-center rounded-xl shadow-md hover:shadow-lg transition-shadow">
|
|
||||||
<h3 className="text-xl font-bold text-[#48286e] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{officer.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{officer.title}
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Board Members Grid */}
|
|
||||||
<section className="py-12 bg-gray-50 rounded-2xl">
|
|
||||||
<div className="max-w-6xl mx-auto px-8">
|
|
||||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Board of Directors
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
{boardMembers.map((member, index) => (
|
|
||||||
<Card key={index} className="bg-[#eeebf4] p-6 text-center rounded-xl shadow-md hover:shadow-lg transition-shadow">
|
|
||||||
<h3 className="text-xl font-bold text-[#48286e]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{member}
|
|
||||||
</h3>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Join the Board Section */}
|
{/* Join the Board Section */}
|
||||||
<section className="py-12">
|
<section className="py-12 bg-white mt-12 ">
|
||||||
<div className="max-w-4xl mx-auto">
|
{/* content containter */}
|
||||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<div className="w-full mx-auto flex flex-col px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||||
|
<h2 className="text-xl mx-auto sm:text-2xl md:text-4xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
Join the Board of Directors
|
Join the Board of Directors
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-[#48286e] text-center mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="lg:text-2xl text-md md:text-lg max-w-4xl mx-auto justify-center font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
Our elections take place at our December holiday social. Here are some things
|
Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors.
|
||||||
to know if you are thinking about serving on the Board of Directors.
|
|
||||||
</p>
|
</p>
|
||||||
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg">
|
{/* card */}
|
||||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
||||||
<li>
|
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
Nominations are due by November 1. Nomination Form:{' '}
|
<li>
|
||||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
Nominations are due by November 1. Nomination Form:{' '}
|
||||||
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
||||||
Click Here
|
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
||||||
</a>
|
Click Here
|
||||||
</li>
|
</a>
|
||||||
<li>Nominees must have been a member for at least 1 year and current with their dues.</li>
|
</li>
|
||||||
<li>Officer positions are only available to current directors.</li>
|
<li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li>
|
||||||
<li>Each director shall serve a 2 year term.</li>
|
<li>Officer positions are only available to current directors.</li>
|
||||||
<li>The time commitment is 1-2 hours per week.</li>
|
|
||||||
<li>The tasks that directors perform depend on individual interests, skills, and time available.</li>
|
<li>Each director shall serve a 2-year term.</li>
|
||||||
<li>Directors must attend Board meetings which are held the second Thursday of each month at 6:30pm via Zoom.</li>
|
|
||||||
<li>We are a fun group, and we would love for you to join us in providing this service for our community.</li>
|
<li>The time commitment is approximately 1–2 hours per week.</li>
|
||||||
</ol>
|
|
||||||
</Card>
|
<li>
|
||||||
|
The tasks that directors perform depend on individual interests. Recent
|
||||||
|
tasks include researching how to obtain an extra PO Box key, ordering
|
||||||
|
Welcome Team name tags, taking pictures at events, researching new venues
|
||||||
|
for holiday socials, and monitoring Facebook posts. For more information
|
||||||
|
about director duties, see Article 2 of the bylaws in the Members Only
|
||||||
|
section of the website:
|
||||||
|
<a
|
||||||
|
href="https://loaftx.org/bylaws/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#48286e] underline"
|
||||||
|
>
|
||||||
|
https://loaftx.org/bylaws/
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
Directors must attend Board meetings held on the second Thursday of each
|
||||||
|
month at 6:30pm via Zoom.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
We are a fun group, and we would love for you to join us in providing this
|
||||||
|
service for our community.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Checkbox } from '../components/ui/checkbox';
|
|||||||
import { Mail, MapPin, Loader2 } from 'lucide-react';
|
import { Mail, MapPin, Loader2 } from 'lucide-react';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { PiMapTrifoldBold } from "react-icons/pi";
|
||||||
const ContactUs = () => {
|
const ContactUs = () => {
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@@ -103,8 +103,8 @@ const ContactUs = () => {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
||||||
{/* Contact Form */}
|
{/* Contact Form */}
|
||||||
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
<Card className="p-8 bg-white rounded-2xl">
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h1 className="text-2xl sm:text-[28px] leading-5 font-bold text-[#48286e] mb-12" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
Contact Form
|
Contact Form
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ const ContactUs = () => {
|
|||||||
name="firstName"
|
name="firstName"
|
||||||
value={formData.firstName}
|
value={formData.firstName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
className="border-2 border-[#ddd8eb] bg-[#eaedf4] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -134,7 +134,7 @@ const ContactUs = () => {
|
|||||||
name="lastName"
|
name="lastName"
|
||||||
value={formData.lastName}
|
value={formData.lastName}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -152,7 +152,7 @@ const ContactUs = () => {
|
|||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -168,7 +168,7 @@ const ContactUs = () => {
|
|||||||
name="subject"
|
name="subject"
|
||||||
value={formData.subject}
|
value={formData.subject}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -184,7 +184,7 @@ const ContactUs = () => {
|
|||||||
name="message"
|
name="message"
|
||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-2xl min-h-[150px] px-4 py-3 resize-none"
|
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-2xl min-h-[150px] px-4 py-3 resize-none"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -226,21 +226,21 @@ const ContactUs = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Message Card */}
|
{/* Message Card */}
|
||||||
<Card className="p-8 bg-gradient-to-r from-[#664fa3] to-[#48286e] rounded-2xl shadow-lg text-white">
|
<Card className="p-8 bg-gradient-to-r from-[#664fa3] to-[#48286e] rounded-2xl shadow-lg text-white">
|
||||||
<p className="text-xl leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-[28px] font-semibold leading-relaxed" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
If you have questions, or are interested in joining, we would love hearing from you.
|
If you have questions, or are interested in joining, we would love hearing from you.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Email Card */}
|
{/* Email Card */}
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
<Card className="p-6 bg-white rounded-2xl">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="w-12 h-12 bg-[#e8e0f5] rounded-full flex items-center justify-center flex-shrink-0">
|
<div className="flex items-center justify-center flex-shrink-0">
|
||||||
<Mail className="h-6 w-6 text-[#664fa3]" />
|
<Mail className="size-12 text-[#664fa3]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a
|
<a
|
||||||
href="mailto:info@loaftx.org"
|
href="mailto:info@loaftx.org"
|
||||||
className="text-[#664fa3] text-xl font-semibold hover:text-[#48286e] transition-colors"
|
className="text-[#865edf] text-xl font-semibold hover:text-[#48286e] transition-colors"
|
||||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
>
|
>
|
||||||
info@loaftx.org
|
info@loaftx.org
|
||||||
@@ -250,16 +250,16 @@ const ContactUs = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Address Card */}
|
{/* Address Card */}
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
<Card className="p-6 bg-white rounded-2xl ">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-12 h-12 bg-[#e8e0f5] rounded-full flex items-center justify-center flex-shrink-0">
|
<div className="flex items-center justify-center flex-shrink-0">
|
||||||
<MapPin className="h-6 w-6 text-[#664fa3]" />
|
<PiMapTrifoldBold className="size-12 text-[#664fa3]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[#48286e] text-lg font-semibold mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<p className="text-[#48286e] text-[28px] font-semibold mb-1" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
LOAF
|
LOAF
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[#664fa3] text-base leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-[#48286e] text-[28px] font-semibold leading-relaxed" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
P.O. Box 7207<br />
|
P.O. Box 7207<br />
|
||||||
Houston, Texas 77248-7207
|
Houston, Texas 77248-7207
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ const Donate = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen ]">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<main className="bg-gradient-to-b from-white to-[#f1eef9] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
<main className="bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
<div className="max-w-4xl mx-auto text-center">
|
||||||
@@ -68,7 +68,7 @@ const Donate = () => {
|
|||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Donate
|
Donate
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-xl text-[#48286e] font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
We really appreciate your donations. You can make your donation online
|
We really appreciate your donations. You can make your donation online
|
||||||
or send a check by mail.
|
or send a check by mail.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { LuArrowDown } from "react-icons/lu";
|
|||||||
const CardSection = ({ children, className = '', arrow = true }) => (
|
const CardSection = ({ children, className = '', arrow = true }) => (
|
||||||
<section className={` ${className}`}>
|
<section className={` ${className}`}>
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<Card className="p-14 bg-white rounded-2xl">
|
<Card className="p-14 bg-white rounded-3xl">
|
||||||
{children}
|
{children}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,14 +49,14 @@ const History = () => {
|
|||||||
<main className="bg-gradient-to-br from-[#F9FAFB] to-[#DCD7EA] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
<main className="bg-gradient-to-br from-[#F9FAFB] to-[#DCD7EA] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="py-12">
|
<section className="py-12">
|
||||||
<div className="max-w-3xl mx-auto flex justify-between ">
|
<div className="max-w-3xl mx-auto flex justify-around mb-12 flex-col gap-6 items-center lg:flex-row">
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-4"
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-[#48286e] "
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
History of LOAF
|
History of LOAF
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center justify-center gap-2 text-[#48286e]">
|
<div className="flex items-center justify-center gap-6 text-[#48286e]">
|
||||||
<Pen className="h-5 w-5" />
|
<Pen className="size-7" />
|
||||||
<p className="text-md" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
By Arden Eversmeyer
|
By Arden Eversmeyer
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const Landing = () => {
|
|||||||
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
|
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
|
||||||
|
|
||||||
const InfoCard = ({ iconSrc, infoTitle, description }) => (
|
const InfoCard = ({ iconSrc, infoTitle, description }) => (
|
||||||
<Card className="relative bg-white rounded-2xl overflow-visible flex flex-col gap-3.5 items-center pt-16 pb-0 w-full max-w-[363px] md:max-w-none lg:max-w-[363px]">
|
<Card className="relative bg-white rounded-2xl overflow-visible flex flex-col gap-3.5 items-center pt-16 pb-0 w-full max-w-none lg:max-w-[363px]">
|
||||||
<div className="absolute -top-20 md:-top-40 flex justify-center w-full">
|
<div className="absolute -top-20 md:-top-40 flex justify-center w-full">
|
||||||
<img
|
<img
|
||||||
src={iconSrc}
|
src={iconSrc}
|
||||||
@@ -110,7 +110,7 @@ const Landing = () => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Cards Section */}
|
{/* Feature Cards Section */}
|
||||||
<section className="bg-gradient-to-b pb-20 from-[#f1eef9] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-4 md:py-20 lg:py-30 flex flex-col md:w-full lg:flex-row gap-40 md:gap-64 lg:gap-8 items-stretch justify-center">
|
<section className="bg-gradient-to-b pb-20 from-[#f1eef9] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-4 md:py-20 lg:py-30 flex flex-col sm:w-full lg:flex-row gap-40 md:gap-64 lg:gap-8 items-stretch justify-center">
|
||||||
{infoCardData.map((card) => (
|
{infoCardData.map((card) => (
|
||||||
<InfoCard key={card.infoTitle} {...card} />
|
<InfoCard key={card.infoTitle} {...card} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -10,33 +10,31 @@ const MissionValues = () => {
|
|||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-white">
|
||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
<main className="bg-gradient-to-b from-[#f9fafb] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-12 md:py-16">
|
<main className="bg-gradient-to-b from-[#f9fafb] to-[#ddd8eb] px-4 sm:px-6 py-8 sm:py-12 md:py-20">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-[1400px] mx-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="flex md:flex-row flex-col gap-10 items-stretch">
|
||||||
{/* Left Card - Mission (Purple Gradient) */}
|
{/* Left Card - Mission (Purple Gradient) */}
|
||||||
<Card className="bg-gradient-to-br from-[#664fa3] to-[#48286e] p-8 rounded-2xl shadow-lg">
|
<Card className=" bg-gradient-to-br from-[#664fa3] to-[#48286e] p-16 rounded-2xl shadow-lg flex flex-col items-center justify-between flex-1 w-full md:w-1/2 ">
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white text-center mb-6"
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white text-center mb-6"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
LOAF Mission
|
LOAF Mission
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-white text-lg text-center leading-relaxed"
|
<p className="text-white text-xl text-center leading-relaxed"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
LOAF's mission is to alleviate isolation and enrich the lives of lesbians
|
LOAF’s mission is to alleviate isolation and enrich the lives of lesbians over the age of 50 by providing several social networking events every month in Houston and the surrounding areas.
|
||||||
over the age of 50 by providing several social networking events every month
|
|
||||||
in Houston and the surrounding areas.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
<img src={loafLogo} alt="LOAF Logo" className="w-32 h-32 sm:w-40 sm:h-40 md:w-48 md:h-48 lg:w-64 lg:h-64 object-contain" />
|
<img src={loafLogo} alt="LOAF Logo" className="size-32 sm:size-40 md:size-64 lg:size-96 object-contain" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Right Card - Values */}
|
{/* Right Card - Values */}
|
||||||
<Card className="bg-white p-8 rounded-2xl shadow-lg">
|
<Card className="bg-white p-16 rounded-2xl shadow-lg flex-1 w-full md:w-1/2 ">
|
||||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] text-center mb-6"
|
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] text-center mb-6"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
LOAF Values
|
LOAF Values
|
||||||
</h2>
|
</h2>
|
||||||
<ol className="list-decimal list-inside space-y-3 text-lg text-[#48286e]"
|
<ol className="list-decimal list-inside space-y-8 text-lg text-[#48286e]"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<li>Safe environments for lesbians to gather for a variety of social activities and interaction.</li>
|
<li>Safe environments for lesbians to gather for a variety of social activities and interaction.</li>
|
||||||
<li>Social support for lesbians.</li>
|
<li>Social support for lesbians.</li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import api from '../utils/api';
|
import api from '../utils/api';
|
||||||
import { Card } from '../components/ui/card';
|
import { Card } from '../components/ui/card';
|
||||||
@@ -9,8 +9,7 @@ import { Textarea } from '../components/ui/textarea';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import Navbar from '../components/Navbar';
|
import Navbar from '../components/Navbar';
|
||||||
import MemberFooter from '../components/MemberFooter';
|
import MemberFooter from '../components/MemberFooter';
|
||||||
import { User, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } from 'lucide-react';
|
import { User, Save, Lock, Heart, Users, Mail, BookUser } from 'lucide-react';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '../components/ui/avatar';
|
|
||||||
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
import ChangePasswordDialog from '../components/ChangePasswordDialog';
|
||||||
|
|
||||||
const Profile = () => {
|
const Profile = () => {
|
||||||
@@ -18,12 +17,6 @@ const Profile = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [profileData, setProfileData] = useState(null);
|
const [profileData, setProfileData] = useState(null);
|
||||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||||
const [profilePhotoUrl, setProfilePhotoUrl] = useState(null);
|
|
||||||
const [previewImage, setPreviewImage] = useState(null);
|
|
||||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50); // Default 50MB
|
|
||||||
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800); // Default 50MB in bytes
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
// Personal Information
|
// Personal Information
|
||||||
first_name: '',
|
first_name: '',
|
||||||
@@ -56,27 +49,13 @@ const Profile = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
|
||||||
fetchProfile();
|
fetchProfile();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/config');
|
|
||||||
setMaxFileSizeMB(response.data.max_file_size_mb);
|
|
||||||
setMaxFileSizeBytes(response.data.max_file_size_bytes);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch config, using defaults:', error);
|
|
||||||
// Keep default values if fetch fails
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchProfile = async () => {
|
const fetchProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/users/profile');
|
const response = await api.get('/users/profile');
|
||||||
setProfileData(response.data);
|
setProfileData(response.data);
|
||||||
setProfilePhotoUrl(response.data.profile_photo_url);
|
|
||||||
setPreviewImage(response.data.profile_photo_url);
|
|
||||||
setFormData({
|
setFormData({
|
||||||
// Personal Information
|
// Personal Information
|
||||||
first_name: response.data.first_name || '',
|
first_name: response.data.first_name || '',
|
||||||
@@ -145,57 +124,6 @@ const Profile = () => {
|
|||||||
'Hospitality'
|
'Hospitality'
|
||||||
];
|
];
|
||||||
|
|
||||||
const handlePhotoUpload = async (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
toast.error('Please select an image file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size
|
|
||||||
if (file.size > maxFileSizeBytes) {
|
|
||||||
toast.error(`File size must be less than ${maxFileSizeMB}MB`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadingPhoto(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await api.post('/members/profile/upload-photo', formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
});
|
|
||||||
|
|
||||||
setProfilePhotoUrl(response.data.profile_photo_url);
|
|
||||||
setPreviewImage(response.data.profile_photo_url);
|
|
||||||
toast.success('Profile photo updated successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to upload photo');
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhotoDelete = async () => {
|
|
||||||
if (!profilePhotoUrl) return;
|
|
||||||
|
|
||||||
setUploadingPhoto(true);
|
|
||||||
try {
|
|
||||||
await api.delete('/members/profile/delete-photo');
|
|
||||||
setProfilePhotoUrl(null);
|
|
||||||
setPreviewImage(null);
|
|
||||||
toast.success('Profile photo deleted successfully!');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to delete photo');
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -277,59 +205,6 @@ const Profile = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile Photo Section */}
|
|
||||||
<div className="pb-8 mb-8 border-b border-[#ddd8eb]">
|
|
||||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
<Camera className="h-6 w-6 text-[#664fa3]" />
|
|
||||||
Profile Photo
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
|
||||||
<Avatar className="h-32 w-32 border-4 border-[#ddd8eb]">
|
|
||||||
<AvatarImage src={previewImage} alt="Profile" />
|
|
||||||
<AvatarFallback className="bg-[#f1eef9] text-[#664fa3] text-3xl">
|
|
||||||
{profileData?.first_name?.charAt(0)}{profileData?.last_name?.charAt(0)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handlePhotoUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploadingPhoto}
|
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full px-6 py-3"
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4 mr-2" />
|
|
||||||
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{profilePhotoUrl && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handlePhotoDelete}
|
|
||||||
disabled={uploadingPhoto}
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-6 py-3"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete Photo
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Upload a profile photo (Max {maxFileSizeMB}MB)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Editable Form */}
|
{/* Editable Form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-6" data-testid="profile-form">
|
<form onSubmit={handleSubmit} className="space-y-6" data-testid="profile-form">
|
||||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import PublicFooter from '../components/PublicFooter';
|
|||||||
import { Card } from '../components/ui/card';
|
import { Card } from '../components/ui/card';
|
||||||
import { ChevronDown, ExternalLink, Phone, Mail, MapPin } from 'lucide-react';
|
import { ChevronDown, ExternalLink, Phone, Mail, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FaFlag, FaHeartbeat, FaUtensils } from "react-icons/fa";
|
||||||
const Resources = () => {
|
const Resources = () => {
|
||||||
const [openAccordions, setOpenAccordions] = useState({});
|
const [openAccordions, setOpenAccordions] = useState({});
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ const Resources = () => {
|
|||||||
const categories = [
|
const categories = [
|
||||||
{
|
{
|
||||||
title: 'General LGBTQ+',
|
title: 'General LGBTQ+',
|
||||||
|
icon: <FaFlag />,
|
||||||
resources: [
|
resources: [
|
||||||
{
|
{
|
||||||
name: 'SPRY (Seniors Preparing for Rainbow Years)',
|
name: 'SPRY (Seniors Preparing for Rainbow Years)',
|
||||||
@@ -52,6 +54,7 @@ const Resources = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Healthcare',
|
title: 'Healthcare',
|
||||||
|
icon: <FaHeartbeat />,
|
||||||
resources: [
|
resources: [
|
||||||
{
|
{
|
||||||
name: 'LHI (Lesbian Health Initiative)',
|
name: 'LHI (Lesbian Health Initiative)',
|
||||||
@@ -67,6 +70,7 @@ const Resources = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Food Assistance',
|
title: 'Food Assistance',
|
||||||
|
icon: <FaUtensils />,
|
||||||
resources: [
|
resources: [
|
||||||
{
|
{
|
||||||
name: 'Meals on Wheels',
|
name: 'Meals on Wheels',
|
||||||
@@ -99,12 +103,10 @@ const Resources = () => {
|
|||||||
<main className="bg-gradient-to-b from-white via-[#f1eef9] to-[#e8e0f5] px-6 py-16">
|
<main className="bg-gradient-to-b from-white via-[#f1eef9] to-[#e8e0f5] px-6 py-16">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<section className="max-w-7xl mx-auto mb-12">
|
<section className="max-w-7xl mx-auto mb-12">
|
||||||
<h1 className="text-4xl md:text-5xl font-bold text-[#48286e] text-center mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h1 className="text-[28px] font-bold text-[#48286e] text-center mb-12" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Resources
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl text-[#48286e] text-center max-w-3xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Tap or click on each purple tab below to open and read its contents
|
Tap or click on each purple tab below to open and read its contents
|
||||||
</p>
|
</h1>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Resources Grid */}
|
{/* Resources Grid */}
|
||||||
@@ -113,7 +115,8 @@ const Resources = () => {
|
|||||||
{categories.map((category, categoryIndex) => (
|
{categories.map((category, categoryIndex) => (
|
||||||
<div key={categoryIndex} className="space-y-6">
|
<div key={categoryIndex} className="space-y-6">
|
||||||
{/* Category Title */}
|
{/* Category Title */}
|
||||||
<h2 className="text-3xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<div className="flex justify-center text-4xl text-[#664ea2]">{category.icon}</div>
|
||||||
|
<h2 className="text-[32px] leading-6 font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
{category.title}
|
{category.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -123,27 +126,29 @@ const Resources = () => {
|
|||||||
const isExpanded = isOpen(categoryIndex, resourceIndex);
|
const isExpanded = isOpen(categoryIndex, resourceIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={resourceIndex} className="overflow-hidden">
|
<div key={resourceIndex} className="overflow-hidden ">
|
||||||
{/* Accordion Button */}
|
{/* Accordion Button */}
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleAccordion(categoryIndex, resourceIndex)}
|
onClick={() => toggleAccordion(categoryIndex, resourceIndex)}
|
||||||
className="w-full bg-[#664fa3] hover:bg-[#5a4290] text-white px-6 py-4 rounded-full flex items-center justify-between transition-all shadow-lg hover:shadow-xl"
|
className={`w-full bg-gradient-to-tr from-[#48286E] to-[#664FA3] hover:bg-[#5a4290] text-white px-6 py-4 rounded-3xl flex items-center justify-between transition-all ${isExpanded ? 'rounded-b-none rounded-t-3xl' : ''}`
|
||||||
|
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="text-lg font-semibold text-left" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<span className="text-lg font-semibold text-left" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
{resource.name}
|
{resource.name}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-6 w-6 flex-shrink-0 ml-3 transition-transform duration-300 ${isExpanded ? 'rotate-180' : ''
|
className={`h-6 w-6 flex-shrink-0 ml-3 transition-transform duration-300
|
||||||
}`}
|
${isExpanded ? 'rotate-180' : ''}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Accordion Content */}
|
{/* Accordion Content */}
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 ease-in-out ${isExpanded ? 'max-h-[1000px] opacity-100 mt-3' : 'max-h-0 opacity-0'
|
className={`transition-all duration-300 ease-in-out ${isExpanded ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-lg">
|
<Card className="p-6 bg-white rounded-b-2xl rounded-t-none border-none ">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-[#48286e] mb-4 leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-[#48286e] mb-4 leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
{resource.description}
|
{resource.description}
|
||||||
@@ -170,7 +175,7 @@ const Resources = () => {
|
|||||||
<div className="flex flex-col gap-1 ml-0">
|
<div className="flex flex-col gap-1 ml-0">
|
||||||
{resource.phone && (
|
{resource.phone && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="size-4" />
|
||||||
<a
|
<a
|
||||||
href={`tel:${resource.phone.replace(/[^0-9]/g, '')}`}
|
href={`tel:${resource.phone.replace(/[^0-9]/g, '')}`}
|
||||||
className="text-sm hover:text-[#48286e] transition-colors"
|
className="text-sm hover:text-[#48286e] transition-colors"
|
||||||
|
|||||||
@@ -1,472 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Card } from '../../components/ui/card';
|
|
||||||
import { Button } from '../../components/ui/button';
|
|
||||||
import { Input } from '../../components/ui/input';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '../../components/ui/select';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '../../components/ui/dropdown-menu';
|
|
||||||
import { Badge } from '../../components/ui/badge';
|
|
||||||
import api from '../../utils/api';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
DollarSign,
|
|
||||||
Heart,
|
|
||||||
Users,
|
|
||||||
Globe,
|
|
||||||
Search,
|
|
||||||
Loader2,
|
|
||||||
Download,
|
|
||||||
FileDown,
|
|
||||||
Calendar
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
const AdminDonations = () => {
|
|
||||||
const [donations, setDonations] = useState([]);
|
|
||||||
const [filteredDonations, setFilteredDonations] = useState([]);
|
|
||||||
const [stats, setStats] = useState({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [typeFilter, setTypeFilter] = useState('all');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
|
||||||
const [startDate, setStartDate] = useState('');
|
|
||||||
const [endDate, setEndDate] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
filterDonations();
|
|
||||||
}, [searchQuery, typeFilter, statusFilter, startDate, endDate, donations]);
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const [donationsResponse, statsResponse] = await Promise.all([
|
|
||||||
api.get('/admin/donations'),
|
|
||||||
api.get('/admin/donations/stats')
|
|
||||||
]);
|
|
||||||
|
|
||||||
setDonations(donationsResponse.data);
|
|
||||||
setStats(statsResponse.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch donation data:', error);
|
|
||||||
toast.error('Failed to load donation data');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filterDonations = () => {
|
|
||||||
let filtered = [...donations];
|
|
||||||
|
|
||||||
// Search filter (donor name or email)
|
|
||||||
if (searchQuery.trim()) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
filtered = filtered.filter(donation =>
|
|
||||||
donation.donor_name?.toLowerCase().includes(query) ||
|
|
||||||
donation.donor_email?.toLowerCase().includes(query)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type filter
|
|
||||||
if (typeFilter !== 'all') {
|
|
||||||
filtered = filtered.filter(donation => donation.donation_type === typeFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status filter
|
|
||||||
if (statusFilter !== 'all') {
|
|
||||||
filtered = filtered.filter(donation => donation.status === statusFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date range filter
|
|
||||||
if (startDate) {
|
|
||||||
filtered = filtered.filter(donation =>
|
|
||||||
new Date(donation.created_at) >= new Date(startDate)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
filtered = filtered.filter(donation =>
|
|
||||||
new Date(donation.created_at) <= new Date(endDate)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFilteredDonations(filtered);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = async (exportType) => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const params = exportType === 'current' ? {
|
|
||||||
donation_type: typeFilter !== 'all' ? typeFilter : undefined,
|
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
||||||
start_date: startDate || undefined,
|
|
||||||
end_date: endDate || undefined,
|
|
||||||
search: searchQuery || undefined
|
|
||||||
} : {};
|
|
||||||
|
|
||||||
const response = await api.get('/admin/donations/export', {
|
|
||||||
params,
|
|
||||||
responseType: 'blob'
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute('download', `donations_export_${new Date().toISOString().split('T')[0]}.csv`);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
toast.success('Donations exported successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to export donations:', error);
|
|
||||||
toast.error('Failed to export donations');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPrice = (cents) => {
|
|
||||||
return `$${(cents / 100).toFixed(2)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
if (!dateString) return 'N/A';
|
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadgeVariant = (status) => {
|
|
||||||
const variants = {
|
|
||||||
completed: 'default',
|
|
||||||
pending: 'secondary',
|
|
||||||
failed: 'destructive'
|
|
||||||
};
|
|
||||||
return variants[status] || 'outline';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeBadgeColor = (type) => {
|
|
||||||
return type === 'member' ? 'bg-[#81B29A]' : 'bg-[#664fa3]';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-[#664fa3]" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Donation Management
|
|
||||||
</h1>
|
|
||||||
<p className="text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Track and manage all donations from members and the public
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid md:grid-cols-4 gap-6">
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Total Donations
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold text-[#422268] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{stats.total_donations || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-[#DDD8EB]/20 rounded-full">
|
|
||||||
<Heart className="h-6 w-6 text-[#664fa3]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Member Donations
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold text-[#81B29A] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{stats.member_donations || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-[#81B29A]/10 rounded-full">
|
|
||||||
<Users className="h-6 w-6 text-[#81B29A]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Public Donations
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold text-[#664fa3] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{stats.public_donations || 0}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-[#DDD8EB]/20 rounded-full">
|
|
||||||
<Globe className="h-6 w-6 text-[#664fa3]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Total Amount
|
|
||||||
</p>
|
|
||||||
<p className="text-3xl font-bold text-[#422268] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{stats.total_amount || '$0.00'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-[#DDD8EB]/20 rounded-full">
|
|
||||||
<DollarSign className="h-6 w-6 text-[#664fa3]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters and Actions */}
|
|
||||||
<Card className="p-6 bg-white rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Search and Export Row */}
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 justify-between">
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search by donor name or email..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
disabled={exporting}
|
|
||||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
{exporting ? 'Exporting...' : 'Export'}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleExport('all')}
|
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
|
||||||
>
|
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
|
||||||
<span className="text-[#422268]">Export All Donations</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleExport('current')}
|
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
|
||||||
>
|
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
|
||||||
<span className="text-[#422268]">Export Current View</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters Row */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
|
||||||
<SelectTrigger className="rounded-full border-2 border-[#ddd8eb]">
|
|
||||||
<SelectValue placeholder="All Types" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Types</SelectItem>
|
|
||||||
<SelectItem value="member">Member Donations</SelectItem>
|
|
||||||
<SelectItem value="public">Public Donations</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
||||||
<SelectTrigger className="rounded-full border-2 border-[#ddd8eb]">
|
|
||||||
<SelectValue placeholder="All Statuses" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
|
||||||
<SelectItem value="completed">Completed</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="failed">Failed</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="rounded-full border-2 border-[#ddd8eb]"
|
|
||||||
placeholder="Start Date"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="rounded-full border-2 border-[#ddd8eb]"
|
|
||||||
placeholder="End Date"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters Summary */}
|
|
||||||
{(searchQuery || typeFilter !== 'all' || statusFilter !== 'all' || startDate || endDate) && (
|
|
||||||
<div className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
Showing {filteredDonations.length} of {donations.length} donations
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Donations Table */}
|
|
||||||
<Card className="bg-white rounded-2xl border-2 border-[#ddd8eb] overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="bg-[#f1eef9] border-b-2 border-[#ddd8eb]">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Donor
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Amount
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Status
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Date
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-4 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Payment Method
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-[#ddd8eb]">
|
|
||||||
{filteredDonations.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan="6" className="px-6 py-12 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Heart className="h-12 w-12 text-[#ddd8eb]" />
|
|
||||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{donations.length === 0 ? 'No donations yet' : 'No donations match your filters'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredDonations.map((donation) => (
|
|
||||||
<tr key={donation.id} className="hover:bg-[#f9f5ff] transition-colors">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{donation.donor_name || 'Anonymous'}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{donation.donor_email || 'No email'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<Badge
|
|
||||||
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none rounded-full px-3 py-1`}
|
|
||||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
||||||
>
|
|
||||||
{donation.donation_type === 'member' ? 'Member' : 'Public'}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<p className="font-semibold text-[#422268] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{donation.amount}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<Badge variant={getStatusBadgeVariant(donation.status)} className="rounded-full">
|
|
||||||
{donation.status.charAt(0).toUpperCase() + donation.status.slice(1)}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{formatDate(donation.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{donation.payment_method || 'N/A'}
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* This Month Summary */}
|
|
||||||
{stats.this_month_count > 0 && (
|
|
||||||
<Card className="p-6 bg-gradient-to-r from-[#f9f5ff] to-[#f1eef9] rounded-2xl border-2 border-[#ddd8eb]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-[#664fa3] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
This Month's Donations
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold text-[#422268] mt-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{stats.this_month_count} donations • {stats.this_month_amount}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-white rounded-full shadow-sm">
|
|
||||||
<Heart className="h-8 w-8 text-[#ff9e77]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AdminDonations;
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
@@ -12,10 +11,9 @@ import CreateStaffDialog from '../../components/CreateStaffDialog';
|
|||||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||||
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye } from 'lucide-react';
|
import { UserCog, Search, Shield, UserPlus, Mail } from 'lucide-react';
|
||||||
|
|
||||||
const AdminStaff = () => {
|
const AdminStaff = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||||
@@ -248,18 +246,6 @@ const AdminStaff = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-4 py-2"
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,18 +30,8 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Calendar,
|
Calendar,
|
||||||
Edit,
|
Edit,
|
||||||
XCircle,
|
XCircle
|
||||||
Download,
|
|
||||||
FileDown,
|
|
||||||
AlertTriangle,
|
|
||||||
Info
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '../../components/ui/dropdown-menu';
|
|
||||||
|
|
||||||
const AdminSubscriptions = () => {
|
const AdminSubscriptions = () => {
|
||||||
const [subscriptions, setSubscriptions] = useState([]);
|
const [subscriptions, setSubscriptions] = useState([]);
|
||||||
@@ -52,7 +42,6 @@ const AdminSubscriptions = () => {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [planFilter, setPlanFilter] = useState('all');
|
const [planFilter, setPlanFilter] = useState('all');
|
||||||
const [exporting, setExporting] = useState(false);
|
|
||||||
|
|
||||||
// Edit subscription dialog state
|
// Edit subscription dialog state
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
@@ -129,62 +118,6 @@ const AdminSubscriptions = () => {
|
|||||||
const handleSaveSubscription = async () => {
|
const handleSaveSubscription = async () => {
|
||||||
if (!selectedSubscription) return;
|
if (!selectedSubscription) return;
|
||||||
|
|
||||||
// Check if status is changing
|
|
||||||
const statusChanged = editFormData.status !== selectedSubscription.status;
|
|
||||||
|
|
||||||
if (statusChanged) {
|
|
||||||
// Get status change consequences
|
|
||||||
let warningMessage = '';
|
|
||||||
let confirmText = '';
|
|
||||||
|
|
||||||
if (editFormData.status === 'cancelled') {
|
|
||||||
warningMessage = `⚠️ CRITICAL: Cancelling this subscription will:
|
|
||||||
|
|
||||||
• Set the user's status to INACTIVE
|
|
||||||
• Remove their member access immediately
|
|
||||||
• Stop all future billing
|
|
||||||
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
|
||||||
|
|
||||||
Current Status: ${selectedSubscription.status.toUpperCase()}
|
|
||||||
New Status: CANCELLED
|
|
||||||
|
|
||||||
Are you absolutely sure you want to proceed?`;
|
|
||||||
confirmText = 'Yes, Cancel Subscription';
|
|
||||||
} else if (editFormData.status === 'expired') {
|
|
||||||
warningMessage = `⚠️ WARNING: Setting this subscription to EXPIRED will:
|
|
||||||
|
|
||||||
• Set the user's status to INACTIVE
|
|
||||||
• Remove their member access
|
|
||||||
• Mark the subscription as ended
|
|
||||||
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
|
||||||
|
|
||||||
Current Status: ${selectedSubscription.status.toUpperCase()}
|
|
||||||
New Status: EXPIRED
|
|
||||||
|
|
||||||
Are you sure you want to proceed?`;
|
|
||||||
confirmText = 'Yes, Mark as Expired';
|
|
||||||
} else if (editFormData.status === 'active') {
|
|
||||||
warningMessage = `✓ Activating this subscription will:
|
|
||||||
|
|
||||||
• Set the user's status to ACTIVE
|
|
||||||
• Grant full member access
|
|
||||||
• Resume billing if applicable
|
|
||||||
• This action affects: ${selectedSubscription.user.first_name} ${selectedSubscription.user.last_name} (${selectedSubscription.user.email})
|
|
||||||
|
|
||||||
Current Status: ${selectedSubscription.status.toUpperCase()}
|
|
||||||
New Status: ACTIVE
|
|
||||||
|
|
||||||
Proceed with activation?`;
|
|
||||||
confirmText = 'Yes, Activate Subscription';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show confirmation dialog
|
|
||||||
const confirmed = window.confirm(warningMessage);
|
|
||||||
if (!confirmed) {
|
|
||||||
return; // User cancelled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
try {
|
try {
|
||||||
await api.put(`/admin/subscriptions/${selectedSubscription.id}`, {
|
await api.put(`/admin/subscriptions/${selectedSubscription.id}`, {
|
||||||
@@ -218,38 +151,6 @@ Proceed with activation?`;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async (exportType) => {
|
|
||||||
setExporting(true);
|
|
||||||
try {
|
|
||||||
const params = exportType === 'current' ? {
|
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
|
||||||
plan_id: planFilter !== 'all' ? planFilter : undefined,
|
|
||||||
search: searchQuery || undefined
|
|
||||||
} : {};
|
|
||||||
|
|
||||||
const response = await api.get('/admin/subscriptions/export', {
|
|
||||||
params,
|
|
||||||
responseType: 'blob'
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.setAttribute('download', `subscriptions_export_${new Date().toISOString().split('T')[0]}.csv`);
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
toast.success('Subscriptions exported successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to export subscriptions:', error);
|
|
||||||
toast.error('Failed to export subscriptions');
|
|
||||||
} finally {
|
|
||||||
setExporting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPrice = (cents) => {
|
const formatPrice = (cents) => {
|
||||||
return `$${(cents / 100).toFixed(2)}`;
|
return `$${(cents / 100).toFixed(2)}`;
|
||||||
};
|
};
|
||||||
@@ -406,39 +307,8 @@ Proceed with activation?`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-between">
|
<div className="mt-4 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<div className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
|
||||||
Showing {filteredSubscriptions.length} of {subscriptions.length} subscriptions
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Export Dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
disabled={exporting}
|
|
||||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-2 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
{exporting ? 'Exporting...' : 'Export'}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleExport('all')}
|
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
|
||||||
>
|
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
|
||||||
<span className="text-[#422268]">Export All Subscriptions</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleExport('current')}
|
|
||||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
|
||||||
>
|
|
||||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
|
||||||
<span className="text-[#422268]">Export Current View</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -672,59 +542,6 @@ Proceed with activation?`;
|
|||||||
<SelectItem value="expired">Expired</SelectItem>
|
<SelectItem value="expired">Expired</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Warning Box - Show when status is different */}
|
|
||||||
{selectedSubscription && editFormData.status !== selectedSubscription.status && (
|
|
||||||
<div className={`mt-3 p-4 rounded-xl border-2 ${
|
|
||||||
editFormData.status === 'cancelled'
|
|
||||||
? 'bg-red-50 border-red-300'
|
|
||||||
: editFormData.status === 'expired'
|
|
||||||
? 'bg-orange-50 border-orange-300'
|
|
||||||
: 'bg-green-50 border-green-300'
|
|
||||||
}`}>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{editFormData.status === 'cancelled' || editFormData.status === 'expired' ? (
|
|
||||||
<AlertTriangle className="h-5 w-5 text-red-600 flex-shrink-0 mt-0.5" />
|
|
||||||
) : (
|
|
||||||
<Info className="h-5 w-5 text-green-600 flex-shrink-0 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="font-semibold text-sm mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{editFormData.status === 'cancelled' && 'Critical: This will cancel the subscription'}
|
|
||||||
{editFormData.status === 'expired' && 'Warning: This will mark subscription as expired'}
|
|
||||||
{editFormData.status === 'active' && 'This will activate the subscription'}
|
|
||||||
</p>
|
|
||||||
<ul className="text-xs space-y-1 list-disc list-inside" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
||||||
{editFormData.status === 'cancelled' && (
|
|
||||||
<>
|
|
||||||
<li>User status will be set to INACTIVE</li>
|
|
||||||
<li>Member access will be removed immediately</li>
|
|
||||||
<li>All future billing will be stopped</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editFormData.status === 'expired' && (
|
|
||||||
<>
|
|
||||||
<li>User status will be set to INACTIVE</li>
|
|
||||||
<li>Member access will be removed</li>
|
|
||||||
<li>Subscription will be marked as ended</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{editFormData.status === 'active' && (
|
|
||||||
<>
|
|
||||||
<li>User status will be set to ACTIVE</li>
|
|
||||||
<li>Full member access will be granted</li>
|
|
||||||
<li>Billing will resume if applicable</li>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
<p className="text-xs mt-2 font-medium">
|
|
||||||
Current: <span className="font-bold">{selectedSubscription.status.toUpperCase()}</span> →
|
|
||||||
New: <span className="font-bold">{editFormData.status.toUpperCase()}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* End Date */}
|
{/* End Date */}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Badge } from '../../components/ui/badge';
|
import { Badge } from '../../components/ui/badge';
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar';
|
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
|
||||||
import { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle, Camera, Upload, Trash2 } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||||
|
|
||||||
@@ -20,13 +19,8 @@ const AdminUserView = () => {
|
|||||||
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
|
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true);
|
||||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState(null);
|
const [pendingAction, setPendingAction] = useState(null);
|
||||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
|
||||||
const [maxFileSizeMB, setMaxFileSizeMB] = useState(50);
|
|
||||||
const [maxFileSizeBytes, setMaxFileSizeBytes] = useState(52428800);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchConfig();
|
|
||||||
fetchUserProfile();
|
fetchUserProfile();
|
||||||
fetchSubscriptions();
|
fetchSubscriptions();
|
||||||
}, [userId]);
|
}, [userId]);
|
||||||
@@ -54,80 +48,6 @@ const AdminUserView = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
try {
|
|
||||||
const response = await api.get('/config');
|
|
||||||
setMaxFileSizeMB(response.data.max_file_size_mb);
|
|
||||||
setMaxFileSizeBytes(response.data.max_file_size_bytes);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch config, using defaults:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhotoUpload = async (e) => {
|
|
||||||
const file = e.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
toast.error('Please select an image file');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size
|
|
||||||
if (file.size > maxFileSizeBytes) {
|
|
||||||
toast.error(`File size must be less than ${maxFileSizeMB}MB`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadingPhoto(true);
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await api.post(`/admin/users/${userId}/upload-photo`, formData, {
|
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update user state with new photo URL
|
|
||||||
setUser(prev => ({
|
|
||||||
...prev,
|
|
||||||
profile_photo_url: response.data.profile_photo_url
|
|
||||||
}));
|
|
||||||
|
|
||||||
toast.success('Profile photo updated successfully');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.response?.data?.detail || 'Failed to upload photo');
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
// Reset file input
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePhotoDelete = async () => {
|
|
||||||
if (!user?.profile_photo_url) return;
|
|
||||||
|
|
||||||
setUploadingPhoto(true);
|
|
||||||
try {
|
|
||||||
await api.delete(`/admin/users/${userId}/delete-photo`);
|
|
||||||
|
|
||||||
// Update user state to remove photo URL
|
|
||||||
setUser(prev => ({
|
|
||||||
...prev,
|
|
||||||
profile_photo_url: null
|
|
||||||
}));
|
|
||||||
|
|
||||||
toast.success('Profile photo deleted successfully');
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.response?.data?.detail || 'Failed to delete photo');
|
|
||||||
} finally {
|
|
||||||
setUploadingPhoto(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPasswordRequest = () => {
|
const handleResetPasswordRequest = () => {
|
||||||
setPendingAction({ type: 'reset_password' });
|
setPendingAction({ type: 'reset_password' });
|
||||||
setConfirmDialogOpen(true);
|
setConfirmDialogOpen(true);
|
||||||
@@ -221,12 +141,9 @@ const AdminUserView = () => {
|
|||||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] mb-8">
|
||||||
<div className="flex items-start gap-6">
|
<div className="flex items-start gap-6">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<Avatar className="h-24 w-24 border-4 border-[#ddd8eb]">
|
<div className="h-24 w-24 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-3xl">
|
||||||
<AvatarImage src={user.profile_photo_url} alt={`${user.first_name} ${user.last_name}`} />
|
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||||
<AvatarFallback className="bg-[#DDD8EB] text-[#422268] font-semibold text-3xl">
|
</div>
|
||||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -290,37 +207,6 @@ const AdminUserView = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Profile Photo Management */}
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handlePhotoUpload}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={uploadingPhoto}
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-[#81B29A] text-[#81B29A] hover:bg-[#E8F5E9] rounded-full px-4 py-2 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4 mr-2" />
|
|
||||||
{uploadingPhoto ? 'Uploading...' : 'Upload Photo'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{user.profile_photo_url && (
|
|
||||||
<Button
|
|
||||||
onClick={handlePhotoDelete}
|
|
||||||
disabled={uploadingPhoto}
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50 rounded-full px-4 py-2 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
|
||||||
Delete Photo
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm text-[#664fa3] ml-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<div className="flex items-center gap-2 text-sm text-[#664fa3] ml-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span>User will receive a temporary password via email</span>
|
<span>User will receive a temporary password via email</span>
|
||||||
|
|||||||
@@ -29,10 +29,9 @@ import {
|
|||||||
PaginationEllipsis,
|
PaginationEllipsis,
|
||||||
} from '../../components/ui/pagination';
|
} from '../../components/ui/pagination';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X } from 'lucide-react';
|
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||||
import RejectionDialog from '../../components/RejectionDialog';
|
|
||||||
|
|
||||||
const AdminValidations = () => {
|
const AdminValidations = () => {
|
||||||
const [pendingUsers, setPendingUsers] = useState([]);
|
const [pendingUsers, setPendingUsers] = useState([]);
|
||||||
@@ -43,8 +42,6 @@ const AdminValidations = () => {
|
|||||||
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
||||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||||
const [pendingAction, setPendingAction] = useState(null);
|
const [pendingAction, setPendingAction] = useState(null);
|
||||||
const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false);
|
|
||||||
const [userToReject, setUserToReject] = useState(null);
|
|
||||||
|
|
||||||
// Filtering state
|
// Filtering state
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -196,28 +193,6 @@ const AdminValidations = () => {
|
|||||||
fetchPendingUsers(); // Refresh list
|
fetchPendingUsers(); // Refresh list
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectUser = (user) => {
|
|
||||||
setUserToReject(user);
|
|
||||||
setRejectionDialogOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmRejection = async (reason) => {
|
|
||||||
if (!userToReject) return;
|
|
||||||
|
|
||||||
setActionLoading(userToReject.id);
|
|
||||||
try {
|
|
||||||
await api.post(`/admin/users/${userToReject.id}/reject`, { reason });
|
|
||||||
toast.success(`${userToReject.first_name} ${userToReject.last_name} has been rejected`);
|
|
||||||
fetchPendingUsers();
|
|
||||||
setRejectionDialogOpen(false);
|
|
||||||
setUserToReject(null);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.response?.data?.detail || 'Failed to reject user');
|
|
||||||
} finally {
|
|
||||||
setActionLoading(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusBadge = (status) => {
|
const getStatusBadge = (status) => {
|
||||||
const config = {
|
const config = {
|
||||||
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
|
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
|
||||||
@@ -385,68 +360,32 @@ const AdminValidations = () => {
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{user.status === 'pending_email' ? (
|
{user.status === 'pending_email' ? (
|
||||||
<>
|
<Button
|
||||||
<Button
|
onClick={() => handleBypassAndValidateRequest(user)}
|
||||||
onClick={() => handleBypassAndValidateRequest(user)}
|
disabled={actionLoading === user.id}
|
||||||
disabled={actionLoading === user.id}
|
size="sm"
|
||||||
size="sm"
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
>
|
||||||
>
|
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleRejectUser(user)}
|
|
||||||
disabled={actionLoading === user.id}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : user.status === 'payment_pending' ? (
|
) : user.status === 'payment_pending' ? (
|
||||||
<>
|
<Button
|
||||||
<Button
|
onClick={() => handleActivatePayment(user)}
|
||||||
onClick={() => handleActivatePayment(user)}
|
size="sm"
|
||||||
size="sm"
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
>
|
||||||
>
|
<CheckCircle className="h-4 w-4 mr-1" />
|
||||||
<CheckCircle className="h-4 w-4 mr-1" />
|
Activate Payment
|
||||||
Activate Payment
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleRejectUser(user)}
|
|
||||||
disabled={actionLoading === user.id}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Button
|
||||||
<Button
|
onClick={() => handleValidateRequest(user)}
|
||||||
onClick={() => handleValidateRequest(user)}
|
disabled={actionLoading === user.id}
|
||||||
disabled={actionLoading === user.id}
|
size="sm"
|
||||||
size="sm"
|
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
>
|
||||||
>
|
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => handleRejectUser(user)}
|
|
||||||
disabled={actionLoading === user.id}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 mr-1" />
|
|
||||||
Reject
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -563,15 +502,6 @@ const AdminValidations = () => {
|
|||||||
loading={actionLoading !== null}
|
loading={actionLoading !== null}
|
||||||
{...getActionMessage()}
|
{...getActionMessage()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rejection Dialog */}
|
|
||||||
<RejectionDialog
|
|
||||||
open={rejectionDialogOpen}
|
|
||||||
onOpenChange={setRejectionDialogOpen}
|
|
||||||
onConfirm={confirmRejection}
|
|
||||||
user={userToReject}
|
|
||||||
loading={actionLoading !== null}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user