Compare commits
31 Commits
37ccfe7767
...
templates
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71931d4a7 | ||
|
|
97cc5bdedf | ||
|
|
8011913c4d | ||
|
|
40a0e3f342 | ||
|
|
968eaccac2 | ||
|
|
11de3d1eed | ||
|
|
11142ec50e | ||
|
|
0249cad261 | ||
|
|
56711e9136 | ||
|
|
03b76a8e58 | ||
|
|
1acb13ba79 | ||
|
|
fa9a1d1d1d | ||
|
|
48802fe0c6 | ||
|
|
8c351773ba | ||
|
|
3511e7a9c8 | ||
|
|
33a4d8f4c4 | ||
|
|
1d70ac4ec7 | ||
|
|
a6c2475092 | ||
|
|
6d777ed583 | ||
|
|
99d65c917f | ||
|
|
0f16264656 | ||
|
|
33fc3a101d | ||
|
|
4093c1603e | ||
| 035cc896df | |||
|
|
8ffa97bcd1 | ||
|
|
b6d25cdab7 | ||
|
|
f3610282f2 | ||
|
|
f1dd7fe75b | ||
| 4848ec3942 | |||
| 41d2466cbf | |||
|
|
8c0d9a2a18 |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||||
|
REACT_APP_BASENAME=/membership
|
||||||
|
PUBLIC_URL=/membership
|
||||||
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
WDS_SOCKET_PORT=443
|
||||||
|
|
||||||
|
# Backend API URL
|
||||||
|
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# App Base Path Configuration
|
||||||
|
# Examples:
|
||||||
|
# - For root path: REACT_APP_BASENAME=
|
||||||
|
# - For subpath: REACT_APP_BASENAME=/membership
|
||||||
|
# - For production: REACT_APP_BASENAME=/membership
|
||||||
|
REACT_APP_BASENAME=
|
||||||
|
|
||||||
|
# Feature Flags
|
||||||
|
REACT_APP_ENABLE_VISUAL_EDITS=false
|
||||||
|
ENABLE_HEALTH_CHECK=false
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
.env.production
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
/node_modules
|
||||||
|
|||||||
918
README.md
918
README.md
@@ -1,70 +1,912 @@
|
|||||||
# Getting Started with Create React App
|
# LOAF Membership Platform - Frontend
|
||||||
|
|
||||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
React 19-based frontend application for the LOAF (LGBT Organization and Friends) membership management platform.
|
||||||
|
|
||||||
## Available Scripts
|
## Table of Contents
|
||||||
|
|
||||||
In the project directory, you can run:
|
- [Setup & Installation](#setup--installation)
|
||||||
|
- [Architecture & Code Structure](#architecture--code-structure)
|
||||||
|
- [Design System](#design-system)
|
||||||
|
- [Deployment Guide](#deployment-guide)
|
||||||
|
- [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
### `npm start`
|
---
|
||||||
|
|
||||||
Runs the app in the development mode.\
|
## Setup & Installation
|
||||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
|
||||||
|
|
||||||
The page will reload when you make changes.\
|
### Prerequisites
|
||||||
You may also see any lint errors in the console.
|
|
||||||
|
|
||||||
### `npm test`
|
- **Node.js**: 18.0 or higher
|
||||||
|
- **Yarn**: 1.22+ (or npm 8+)
|
||||||
|
- **Backend API**: Running on http://localhost:8000
|
||||||
|
|
||||||
Launches the test runner in the interactive watch mode.\
|
### 1. Install Dependencies
|
||||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
|
||||||
|
|
||||||
### `npm run build`
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
Builds the app for production to the `build` folder.\
|
# Using Yarn (recommended)
|
||||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
yarn install
|
||||||
|
|
||||||
The build is minified and the filenames include the hashes.\
|
# Or using npm
|
||||||
Your app is ready to be deployed!
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
**Key Dependencies:**
|
||||||
|
- `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
|
||||||
|
|
||||||
### `npm run eject`
|
### 2. Environment Configuration
|
||||||
|
|
||||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
Create `.env` file in the frontend directory:
|
||||||
|
|
||||||
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.
|
```bash
|
||||||
|
# Backend API URL
|
||||||
|
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||||
|
|
||||||
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.
|
# Optional: Analytics, Sentry, etc.
|
||||||
|
# REACT_APP_SENTRY_DSN=your-sentry-dsn
|
||||||
|
# REACT_APP_GA_TRACKING_ID=UA-XXXXXXXXX-X
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
**Important:**
|
||||||
|
- All environment variables must start with `REACT_APP_`
|
||||||
|
- Restart development server after changing `.env`
|
||||||
|
|
||||||
## Learn More
|
### 3. Start Development Server
|
||||||
|
|
||||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
```bash
|
||||||
|
# Start development server
|
||||||
|
yarn start
|
||||||
|
|
||||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
# Or with npm
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
### Code Splitting
|
**Development server will be available at:**
|
||||||
|
- Frontend: http://localhost:3000
|
||||||
|
- Auto-reloads on file changes
|
||||||
|
|
||||||
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)
|
### 4. Build for Production
|
||||||
|
|
||||||
### Analyzing the Bundle Size
|
```bash
|
||||||
|
# Create production build
|
||||||
|
yarn build
|
||||||
|
|
||||||
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)
|
# Or with npm
|
||||||
|
npm build
|
||||||
|
```
|
||||||
|
|
||||||
### Making a Progressive Web App
|
Build output will be in `/build` directory.
|
||||||
|
|
||||||
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)
|
### 5. Run Tests
|
||||||
|
|
||||||
### Advanced Configuration
|
```bash
|
||||||
|
# Run tests in watch mode
|
||||||
|
yarn test
|
||||||
|
|
||||||
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)
|
# Run tests with coverage
|
||||||
|
yarn test --coverage
|
||||||
|
|
||||||
### Deployment
|
# Or with npm
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
---
|
||||||
|
|
||||||
### `npm run build` fails to minify
|
## Architecture & Code Structure
|
||||||
|
|
||||||
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)
|
### 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:
|
||||||
|
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
|
||||||
|
|
||||||
|
**Error:** `Module not found: Can't resolve 'package-name'`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Clear node_modules and reinstall
|
||||||
|
rm -rf node_modules yarn.lock
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# Or with npm
|
||||||
|
rm -rf node_modules package-lock.json
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. CORS Errors
|
||||||
|
|
||||||
|
**Error:** `Access to XMLHttpRequest blocked by CORS policy`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- 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
|
||||||
|
|
||||||
|
**Error:** API returns 401 after some time
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- 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
|
||||||
|
|
||||||
|
**Error:** `process.env.REACT_APP_BACKEND_URL is undefined`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- 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
|
||||||
|
|
||||||
|
**Error:** `npm run build` fails with memory error
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# Increase Node memory limit
|
||||||
|
NODE_OPTIONS=--max_old_space_size=4096 yarn build
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Routing Not Working in Production
|
||||||
|
|
||||||
|
**Error:** Refresh on /dashboard returns 404
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- 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
|
||||||
|
|
||||||
|
**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
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "/membership",
|
"homepage": "/",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
"@fontsource/fraunces": "^5.2.9",
|
"@fontsource/fraunces": "^5.2.9",
|
||||||
@@ -53,9 +53,11 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.56.2",
|
"react-hook-form": "^7.56.2",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-resizable-panels": "^3.0.1",
|
"react-resizable-panels": "^3.0.1",
|
||||||
"react-router-dom": "^7.5.1",
|
"react-router-dom": "^7.5.1",
|
||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.3",
|
"sonner": "^2.0.3",
|
||||||
"tailwind-merge": "^3.2.0",
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|||||||
BIN
public/friendships.png
Normal file
BIN
public/friendships.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -25,7 +25,6 @@
|
|||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
-->
|
-->
|
||||||
<title>LOAF - Lesbians Over Age Fifty</title>
|
<title>LOAF - Lesbians Over Age Fifty</title>
|
||||||
<script src="#"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
BIN
public/web_elements_tagline.png
Normal file
BIN
public/web_elements_tagline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 320 KiB |
28
src/App.js
28
src/App.js
@@ -23,9 +23,11 @@ import AdminMembers from './pages/admin/AdminMembers';
|
|||||||
import AdminPermissions from './pages/admin/AdminPermissions';
|
import AdminPermissions from './pages/admin/AdminPermissions';
|
||||||
import AdminRoles from './pages/admin/AdminRoles';
|
import AdminRoles from './pages/admin/AdminRoles';
|
||||||
import AdminEvents from './pages/admin/AdminEvents';
|
import AdminEvents from './pages/admin/AdminEvents';
|
||||||
|
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
||||||
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';
|
||||||
@@ -49,6 +51,8 @@ import Resources from './pages/Resources';
|
|||||||
import ContactUs from './pages/ContactUs';
|
import ContactUs from './pages/ContactUs';
|
||||||
import TermsOfService from './pages/TermsOfService';
|
import TermsOfService from './pages/TermsOfService';
|
||||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||||
|
import AcceptInvitation from './pages/AcceptInvitation';
|
||||||
|
import NotFound from './pages/NotFound';
|
||||||
|
|
||||||
const PrivateRoute = ({ children, adminOnly = false }) => {
|
const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -69,14 +73,19 @@ const PrivateRoute = ({ children, adminOnly = false }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
// Read basename from environment variable (defaults to empty string for root path)
|
||||||
|
// Set REACT_APP_BASENAME in .env to use a subpath (e.g., "/membership")
|
||||||
|
const basename = process.env.REACT_APP_BASENAME || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<BrowserRouter basename="/membership">
|
<BrowserRouter basename={basename}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Landing />} />
|
<Route path="/" element={<Landing />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
|
<Route path="/accept-invitation" element={<AcceptInvitation />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/change-password-required" element={
|
<Route path="/change-password-required" element={
|
||||||
@@ -210,6 +219,13 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/admin/events/:eventId/attendance" element={
|
||||||
|
<PrivateRoute adminOnly>
|
||||||
|
<AdminLayout>
|
||||||
|
<AdminEventAttendance />
|
||||||
|
</AdminLayout>
|
||||||
|
</PrivateRoute>
|
||||||
|
} />
|
||||||
<Route path="/admin/validations" element={
|
<Route path="/admin/validations" element={
|
||||||
<PrivateRoute adminOnly>
|
<PrivateRoute adminOnly>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
@@ -231,6 +247,13 @@ 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>
|
||||||
@@ -266,6 +289,9 @@ function App() {
|
|||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
{/* 404 - Catch all undefined routes */}
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ 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 }) => {
|
||||||
@@ -39,7 +40,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/admin/users');
|
const response = await api.get('/admin/users');
|
||||||
const pending = response.data.filter(u =>
|
const pending = response.data.filter(u =>
|
||||||
['pending_validation', 'pre_validated'].includes(u.status)
|
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
||||||
);
|
);
|
||||||
setPendingCount(pending.length);
|
setPendingCount(pending.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -123,6 +124,12 @@ 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,
|
||||||
@@ -177,43 +184,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
return location.pathname.startsWith(path);
|
return location.pathname.startsWith(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderNavItem = (item) => {
|
||||||
<>
|
if (!item) return null;
|
||||||
{/* Sidebar */}
|
|
||||||
<aside
|
|
||||||
className={`
|
|
||||||
bg-white border-r border-[#ddd8eb] transition-all duration-300 ease-out
|
|
||||||
${isMobile ? 'fixed inset-y-0 left-0 z-40' : 'relative'}
|
|
||||||
${isOpen ? 'w-64' : 'w-16'}
|
|
||||||
${isMobile && !isOpen ? '-translate-x-full' : 'translate-x-0'}
|
|
||||||
flex flex-col
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
|
||||||
{isOpen && (
|
|
||||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
Admin
|
|
||||||
</h2>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors ml-auto"
|
|
||||||
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
|
||||||
>
|
|
||||||
{isMobile ? (
|
|
||||||
<Menu className="h-5 w-5 text-[#422268]" />
|
|
||||||
) : isOpen ? (
|
|
||||||
<ChevronLeft className="h-5 w-5 text-[#422268]" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-5 w-5 text-[#422268]" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Navigation */}
|
|
||||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
|
||||||
{filteredNavItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = isActive(item.path);
|
const active = isActive(item.path);
|
||||||
|
|
||||||
@@ -276,13 +249,127 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
bg-white border-r border-[#ddd8eb] transition-all duration-300 ease-out
|
||||||
|
${isMobile ? 'fixed inset-y-0 left-0 z-40' : 'relative'}
|
||||||
|
${isOpen ? 'w-64' : 'w-16'}
|
||||||
|
${isMobile && !isOpen ? '-translate-x-full' : 'translate-x-0'}
|
||||||
|
flex flex-col
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
||||||
|
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
||||||
|
<img
|
||||||
|
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
||||||
|
alt="LOAF Logo"
|
||||||
|
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Admin
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
View Public Site
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
|
||||||
|
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<Menu className="h-5 w-5 text-[#422268]" />
|
||||||
|
) : isOpen ? (
|
||||||
|
<ChevronLeft className="h-5 w-5 text-[#422268]" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-5 w-5 text-[#422268]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
|
||||||
|
{/* Dashboard - Standalone */}
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
||||||
|
|
||||||
|
{/* MEMBERSHIP Section */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 py-2 mt-6">
|
||||||
|
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
||||||
|
Membership
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff'))}
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Members'))}
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FINANCIALS Section */}
|
||||||
|
{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 && (
|
||||||
|
<div className="px-4 py-2 mt-6">
|
||||||
|
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
||||||
|
Events & Media
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Events'))}
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Gallery'))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DOCUMENTATION Section */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 py-2 mt-6">
|
||||||
|
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
||||||
|
Documentation
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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) */}
|
||||||
|
{user?.role === 'superadmin' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User Section */}
|
{/* User Section */}
|
||||||
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
|
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
|
||||||
{isOpen && user && (
|
{isOpen && user && (
|
||||||
<div className="px-4 py-3 mb-2">
|
<div className="px-4 py-3 mb-2 flex justify-between items-center">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
|
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
|
||||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||||
@@ -296,6 +383,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to='/profile'><Settings size={16} />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -309,8 +398,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
|
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className={`h-2 rounded-full transition-all ${
|
className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
|
||||||
storagePercentage > 90 ? 'bg-red-500' :
|
|
||||||
storagePercentage > 75 ? 'bg-yellow-500' :
|
storagePercentage > 75 ? 'bg-yellow-500' :
|
||||||
'bg-[#81B29A]'
|
'bg-[#81B29A]'
|
||||||
}`}
|
}`}
|
||||||
@@ -324,8 +412,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<HardDrive className={`h-5 w-5 ${
|
<HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
|
||||||
storagePercentage > 90 ? 'text-red-500' :
|
|
||||||
storagePercentage > 75 ? 'text-yellow-500' :
|
storagePercentage > 75 ? 'text-yellow-500' :
|
||||||
'text-[#664fa3]'
|
'text-[#664fa3]'
|
||||||
}`} />
|
}`} />
|
||||||
|
|||||||
@@ -40,15 +40,14 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
|||||||
const fetchRoles = async () => {
|
const fetchRoles = async () => {
|
||||||
setLoadingRoles(true);
|
setLoadingRoles(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/admin/roles');
|
// New endpoint returns roles based on user's permission level
|
||||||
// Filter to show only admin-type roles (not guest or member)
|
// Superadmin: all roles
|
||||||
const staffRoles = response.data.filter(role =>
|
// Admin: admin, finance, and non-elevated custom roles
|
||||||
['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role
|
const response = await api.get('/admin/roles/assignable');
|
||||||
);
|
setRoles(response.data);
|
||||||
setRoles(staffRoles);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch roles:', error);
|
console.error('Failed to fetch assignable roles:', error);
|
||||||
toast.error('Failed to load roles');
|
toast.error('Failed to load roles. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingRoles(false);
|
setLoadingRoles(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,13 +141,35 @@ const Navbar = () => {
|
|||||||
>
|
>
|
||||||
Gallery
|
Gallery
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<DropdownMenu>
|
||||||
to="/members/newsletters"
|
<DropdownMenuTrigger asChild>
|
||||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
<button className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity flex items-center gap-1 bg-transparent border-none cursor-pointer"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
>
|
|
||||||
Documents
|
Documents
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
|
Newsletters
|
||||||
</Link>
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/members/financials" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
|
Financials
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
|
Bylaws
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||||
@@ -297,14 +319,36 @@ const Navbar = () => {
|
|||||||
Gallery
|
Gallery
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Documents Section */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="px-4 py-2 text-white/70 text-sm font-semibold uppercase tracking-wider" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
|
Documents
|
||||||
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to="/members/newsletters"
|
to="/members/newsletters"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
|
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
>
|
>
|
||||||
Documents
|
Newsletters
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/members/financials"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
>
|
||||||
|
Financials
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/members/bylaws"
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||||
|
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||||
|
>
|
||||||
|
Bylaws
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
|
|||||||
107
src/components/RejectionDialog.js
Normal file
107
src/components/RejectionDialog.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
987
src/components/WordPressImportWizard.js
Normal file
987
src/components/WordPressImportWizard.js
Normal file
@@ -0,0 +1,987 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Card } from './ui/card';
|
||||||
|
import { Badge } from './ui/badge';
|
||||||
|
import { Checkbox } from './ui/checkbox';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||||
|
import { Progress } from './ui/progress';
|
||||||
|
import { Alert, AlertDescription } from './ui/alert';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
FileCheck,
|
||||||
|
CheckCircle,
|
||||||
|
Eye,
|
||||||
|
Play,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Trash2,
|
||||||
|
FileDown,
|
||||||
|
Users,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPress Import Wizard Component
|
||||||
|
*
|
||||||
|
* A comprehensive 6-step wizard for importing WordPress users to LOAF platform.
|
||||||
|
* Features:
|
||||||
|
* - CSV upload and analysis
|
||||||
|
* - Interactive status review and adjustment
|
||||||
|
* - Preview before import
|
||||||
|
* - Real-time import progress
|
||||||
|
* - Full rollback capability
|
||||||
|
* - Error reporting
|
||||||
|
*/
|
||||||
|
export default function WordPressImportWizard({ open, onOpenChange, onSuccess }) {
|
||||||
|
// Wizard state
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [importJobId, setImportJobId] = useState(null);
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [uploadedFile, setUploadedFile] = useState(null);
|
||||||
|
const [analysisResult, setAnalysisResult] = useState(null);
|
||||||
|
const [previewData, setPreviewData] = useState([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
|
||||||
|
// Override state
|
||||||
|
const [statusOverrides, setStatusOverrides] = useState({});
|
||||||
|
const [selectedRows, setSelectedRows] = useState(new Set());
|
||||||
|
|
||||||
|
// Import execution state
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importProgress, setImportProgress] = useState(0);
|
||||||
|
const [importResults, setImportResults] = useState(null);
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Step definitions
|
||||||
|
const steps = [
|
||||||
|
{ number: 1, title: 'Upload CSV', icon: Upload },
|
||||||
|
{ number: 2, title: 'Field Mapping', icon: FileCheck },
|
||||||
|
{ number: 3, title: 'Review Status', icon: CheckCircle },
|
||||||
|
{ number: 4, title: 'Preview', icon: Eye },
|
||||||
|
{ number: 5, title: 'Execute', icon: Play },
|
||||||
|
{ number: 6, title: 'Results', icon: CheckCircle2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Reset wizard state when dialog opens/closes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setCurrentStep(1);
|
||||||
|
setImportJobId(null);
|
||||||
|
setUploadedFile(null);
|
||||||
|
setAnalysisResult(null);
|
||||||
|
setPreviewData([]);
|
||||||
|
setStatusOverrides({});
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
setImporting(false);
|
||||||
|
setImportProgress(0);
|
||||||
|
setImportResults(null);
|
||||||
|
}, 300); // Wait for dialog close animation
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step Navigation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const canProceed = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return uploadedFile && analysisResult;
|
||||||
|
case 2:
|
||||||
|
return true; // Field mapping auto-detected
|
||||||
|
case 3:
|
||||||
|
return true; // Status review is optional
|
||||||
|
case 4:
|
||||||
|
return true; // Preview is informational
|
||||||
|
case 5:
|
||||||
|
return !importing;
|
||||||
|
case 6:
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < 6 && canProceed()) {
|
||||||
|
if (currentStep === 3) {
|
||||||
|
// Load preview data when moving from step 3 to 4
|
||||||
|
loadPreviewData(1);
|
||||||
|
}
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 1: Upload CSV
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const handleFileSelect = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
if (!file.name.endsWith('.csv')) {
|
||||||
|
toast.error('Please upload a CSV file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadedFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!uploadedFile) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', uploadedFile);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/admin/import/upload-csv', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
|
||||||
|
setImportJobId(response.data.import_job_id);
|
||||||
|
setAnalysisResult(response.data);
|
||||||
|
toast.success(`CSV analyzed: ${response.data.valid_rows} valid rows, ${response.data.warnings} warnings`);
|
||||||
|
|
||||||
|
// Auto-advance to next step
|
||||||
|
setTimeout(() => setCurrentStep(2), 500);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to upload CSV');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 3: Review & Adjust Status
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const loadPreviewData = async (page = 1) => {
|
||||||
|
if (!importJobId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/admin/import/${importJobId}/preview`, {
|
||||||
|
params: { page, page_size: 50 }
|
||||||
|
});
|
||||||
|
|
||||||
|
setPreviewData(response.data.rows);
|
||||||
|
setCurrentPage(response.data.page);
|
||||||
|
setTotalPages(response.data.total_pages);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to load preview data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 3 && importJobId && previewData.length === 0) {
|
||||||
|
loadPreviewData(1);
|
||||||
|
}
|
||||||
|
}, [currentStep, importJobId]);
|
||||||
|
|
||||||
|
const handleStatusOverride = (rowNum, status) => {
|
||||||
|
setStatusOverrides(prev => ({
|
||||||
|
...prev,
|
||||||
|
[rowNum]: { status }
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkStatusChange = (status) => {
|
||||||
|
const newOverrides = { ...statusOverrides };
|
||||||
|
selectedRows.forEach(rowNum => {
|
||||||
|
newOverrides[rowNum] = { status };
|
||||||
|
});
|
||||||
|
setStatusOverrides(newOverrides);
|
||||||
|
toast.success(`Updated ${selectedRows.size} users to ${status}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRowSelection = (rowNum) => {
|
||||||
|
setSelectedRows(prev => {
|
||||||
|
const newSet = new Set(prev);
|
||||||
|
if (newSet.has(rowNum)) {
|
||||||
|
newSet.delete(rowNum);
|
||||||
|
} else {
|
||||||
|
newSet.add(rowNum);
|
||||||
|
}
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = () => {
|
||||||
|
if (selectedRows.size === previewData.length) {
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedRows(new Set(previewData.map(row => row.row_number)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 5: Execute Import
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const handleExecuteImport = async () => {
|
||||||
|
setImporting(true);
|
||||||
|
setCurrentStep(5);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Start import
|
||||||
|
const response = await api.post(`/admin/import/${importJobId}/execute`, {
|
||||||
|
overrides: statusOverrides,
|
||||||
|
options: {
|
||||||
|
send_password_emails: true,
|
||||||
|
skip_errors: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setImportResults(response.data);
|
||||||
|
toast.success(`Import completed: ${response.data.successful_rows} users imported`);
|
||||||
|
|
||||||
|
// Move to results step
|
||||||
|
setCurrentStep(6);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Import failed');
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Poll for import progress
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 5 && importing && importJobId) {
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/admin/import/${importJobId}/status`);
|
||||||
|
setImportProgress(response.data.progress_percent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch import status:', error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [currentStep, importing, importJobId]);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 6: Rollback
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
|
||||||
|
const [confirmText, setConfirmText] = useState('');
|
||||||
|
|
||||||
|
const handleRollback = async () => {
|
||||||
|
try {
|
||||||
|
await api.post(`/admin/import/${importJobId}/rollback`, { confirm: true });
|
||||||
|
toast.success(`Rolled back ${importResults.successful_rows} users`);
|
||||||
|
onOpenChange(false);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Rollback failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadErrors = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/admin/import/${importJobId}/errors/download`, {
|
||||||
|
responseType: 'blob'
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `import_errors_${importJobId}.csv`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
|
||||||
|
toast.success('Error report downloaded');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to download error report');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Status Badge Component
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const StatusBadge = ({ status }) => {
|
||||||
|
const colors = {
|
||||||
|
active: 'bg-green-100 text-green-800 border-green-300',
|
||||||
|
pre_validated: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||||
|
payment_pending: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||||
|
inactive: 'bg-gray-100 text-gray-800 border-gray-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className={colors[status] || 'bg-gray-100 text-gray-800'}>
|
||||||
|
{status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Render Step Content
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const renderStepContent = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return <Step1Upload />;
|
||||||
|
case 2:
|
||||||
|
return <Step2FieldMapping />;
|
||||||
|
case 3:
|
||||||
|
return <Step3ReviewStatus />;
|
||||||
|
case 4:
|
||||||
|
return <Step4Preview />;
|
||||||
|
case 5:
|
||||||
|
return <Step5Execute />;
|
||||||
|
case 6:
|
||||||
|
return <Step6Results />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 1: Upload CSV
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const Step1Upload = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#422268] mb-2">Upload WordPress CSV Export</h3>
|
||||||
|
<p className="text-sm text-[#664fa3]">
|
||||||
|
Select the WordPress user export CSV file. The file will be analyzed for data quality issues.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6 border-2 border-dashed border-[#ddd8eb] bg-[#f9f5ff]">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Upload className="h-12 w-12 text-[#664fa3]" />
|
||||||
|
<div className="text-center">
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="max-w-xs"
|
||||||
|
/>
|
||||||
|
{uploadedFile && (
|
||||||
|
<p className="text-sm text-[#664fa3] mt-2">
|
||||||
|
Selected: {uploadedFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{uploadedFile && !analysisResult && (
|
||||||
|
<Button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="w-full bg-[#664fa3] hover:bg-[#422268]"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Analyzing CSV...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
Upload and Analyze
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysisResult && (
|
||||||
|
<Card className="p-6 bg-green-50 border-green-200">
|
||||||
|
<h4 className="font-semibold text-green-900 mb-4">Analysis Complete</h4>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-green-700">Total Rows</p>
|
||||||
|
<p className="text-2xl font-semibold text-green-900">{analysisResult.total_rows}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-green-700">Valid Rows</p>
|
||||||
|
<p className="text-2xl font-semibold text-green-900">{analysisResult.valid_rows}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-yellow-700">Warnings</p>
|
||||||
|
<p className="text-2xl font-semibold text-yellow-900">{analysisResult.warnings}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-red-700">Errors</p>
|
||||||
|
<p className="text-2xl font-semibold text-red-900">{analysisResult.errors}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{analysisResult.data_quality && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-green-300">
|
||||||
|
<h5 className="text-sm font-semibold text-green-900 mb-2">Data Quality Issues:</h5>
|
||||||
|
<ul className="text-sm text-green-800 space-y-1">
|
||||||
|
{analysisResult.data_quality.invalid_dob > 0 && (
|
||||||
|
<li>• {analysisResult.data_quality.invalid_dob} invalid dates of birth</li>
|
||||||
|
)}
|
||||||
|
{analysisResult.data_quality.missing_phone > 0 && (
|
||||||
|
<li>• {analysisResult.data_quality.missing_phone} missing phone numbers</li>
|
||||||
|
)}
|
||||||
|
{analysisResult.data_quality.duplicate_email > 0 && (
|
||||||
|
<li>• {analysisResult.data_quality.duplicate_email} duplicate emails</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 2: Field Mapping
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const Step2FieldMapping = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#422268] mb-2">Field Mapping</h3>
|
||||||
|
<p className="text-sm text-[#664fa3]">
|
||||||
|
WordPress fields have been automatically mapped to LOAF platform fields.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>WordPress Field</TableHead>
|
||||||
|
<TableHead>→</TableHead>
|
||||||
|
<TableHead>LOAF Field</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono text-sm">user_email</TableCell>
|
||||||
|
<TableCell>→</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">email</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono text-sm">first_name</TableCell>
|
||||||
|
<TableCell>→</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">first_name</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono text-sm">last_name</TableCell>
|
||||||
|
<TableCell>→</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">last_name</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono text-sm">cell_phone</TableCell>
|
||||||
|
<TableCell>→</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">phone</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
||||||
|
<TableCell>→</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-mono text-sm">wp_capabilities</TableCell>
|
||||||
|
<TableCell>→</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">role + status</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Alert className="bg-blue-50 border-blue-200">
|
||||||
|
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||||
|
<AlertDescription className="text-blue-800">
|
||||||
|
WordPress roles will be automatically converted:
|
||||||
|
<ul className="mt-2 ml-4 space-y-1">
|
||||||
|
<li>• <code>loaf_admin</code> → admin (active)</li>
|
||||||
|
<li>• <code>loaf_treasure</code> → finance (active)</li>
|
||||||
|
<li>• <code>administrator</code> → superadmin (active)</li>
|
||||||
|
<li>• <code>pms_subscription_plan_63</code> → member</li>
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 3: Review & Adjust Status (KEY FEATURE)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const Step3ReviewStatus = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#422268] mb-2">Review & Adjust User Status</h3>
|
||||||
|
<p className="text-sm text-[#664fa3]">
|
||||||
|
Review suggested status mappings and override as needed before import.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk edit toolbar */}
|
||||||
|
<Card className="p-4 bg-[#f9f5ff] border-[#ddd8eb]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.size === previewData.length && previewData.length > 0}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-[#664fa3] font-medium">
|
||||||
|
{selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'}
|
||||||
|
</span>
|
||||||
|
{selectedRows.size > 0 && (
|
||||||
|
<Select onValueChange={handleBulkStatusChange}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="Change status to..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||||
|
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Data table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-[#664fa3]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-[#f9f5ff]">
|
||||||
|
<TableHead className="w-12">
|
||||||
|
<Checkbox checked={false} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>Row</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>WP Role</TableHead>
|
||||||
|
<TableHead>Suggested Status</TableHead>
|
||||||
|
<TableHead>Override Status</TableHead>
|
||||||
|
<TableHead>Issues</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{previewData.map((row) => (
|
||||||
|
<TableRow key={row.row_number}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.has(row.row_number)}
|
||||||
|
onCheckedChange={() => toggleRowSelection(row.row_number)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{row.row_number}</TableCell>
|
||||||
|
<TableCell className="text-sm">{row.email}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{row.first_name} {row.last_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className="bg-[#ddd8eb] text-[#422268]">
|
||||||
|
{row.wordpress_roles?.join(', ') || 'N/A'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusBadge status={row.suggested_status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={statusOverrides[row.row_number]?.status || row.suggested_status}
|
||||||
|
onValueChange={(value) => handleStatusOverride(row.row_number, value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||||
|
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{row.warnings?.map((warning, idx) => (
|
||||||
|
<Badge
|
||||||
|
key={idx}
|
||||||
|
variant="outline"
|
||||||
|
className="text-orange-600 border-orange-300 mr-1"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mr-1" />
|
||||||
|
{warning}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm text-[#664fa3]">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadPreviewData(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1 || loading}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadPreviewData(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages || loading}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 4: Preview
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const Step4Preview = () => {
|
||||||
|
const overrideCount = Object.keys(statusOverrides).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#422268] mb-2">Import Preview</h3>
|
||||||
|
<p className="text-sm text-[#664fa3]">
|
||||||
|
Review the final import settings before execution.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
<Card className="p-6">
|
||||||
|
<p className="text-sm text-[#664fa3]">Total Users</p>
|
||||||
|
<p className="text-3xl font-semibold text-[#422268]">{analysisResult?.total_rows}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-6">
|
||||||
|
<p className="text-sm text-[#664fa3]">Status Overrides</p>
|
||||||
|
<p className="text-3xl font-semibold text-[#422268]">{overrideCount}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-6">
|
||||||
|
<p className="text-sm text-[#664fa3]">Expected Imports</p>
|
||||||
|
<p className="text-3xl font-semibold text-[#422268]">{analysisResult?.valid_rows}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<h4 className="font-semibold text-[#422268] mb-4">Import Options</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
<span className="text-sm text-[#664fa3]">Send password reset emails to all imported users</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
<span className="text-sm text-[#664fa3]">Skip rows with errors and continue import</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
<span className="text-sm text-[#664fa3]">Full rollback capability available after import</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{overrideCount > 0 && (
|
||||||
|
<Alert className="bg-yellow-50 border-yellow-200">
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||||
|
<AlertDescription className="text-yellow-800">
|
||||||
|
You have overridden {overrideCount} user status{overrideCount > 1 ? 'es' : ''}.
|
||||||
|
These will be applied during import.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 5: Execute
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const Step5Execute = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#422268] mb-2">
|
||||||
|
{importing ? 'Import in Progress...' : 'Ready to Import'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#664fa3]">
|
||||||
|
{importing
|
||||||
|
? 'Please wait while users are imported. This may take a few minutes.'
|
||||||
|
: 'Click "Start Import" to begin importing users.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importing && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Progress value={importProgress} className="w-full" />
|
||||||
|
<p className="text-center text-sm text-[#664fa3]">
|
||||||
|
{importProgress.toFixed(1)}% complete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!importing && !importResults && (
|
||||||
|
<Button
|
||||||
|
onClick={handleExecuteImport}
|
||||||
|
className="w-full bg-[#664fa3] hover:bg-[#422268] py-6 text-lg"
|
||||||
|
>
|
||||||
|
<Play className="mr-2 h-5 w-5" />
|
||||||
|
Start Import
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Step 6: Results & Rollback
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const Step6Results = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#422268] mb-2">Import Complete</h3>
|
||||||
|
<p className="text-sm text-[#664fa3]">
|
||||||
|
Review the import results and download error reports if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid md:grid-cols-3 gap-4">
|
||||||
|
<Card className="p-6 bg-green-50 border-green-200">
|
||||||
|
<p className="text-sm text-green-700">Successful Imports</p>
|
||||||
|
<p className="text-4xl font-semibold text-green-900">{importResults?.successful_rows || 0}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-6 bg-red-50 border-red-200">
|
||||||
|
<p className="text-sm text-red-700">Failed Imports</p>
|
||||||
|
<p className="text-4xl font-semibold text-red-900">{importResults?.failed_rows || 0}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-6 bg-blue-50 border-blue-200">
|
||||||
|
<p className="text-sm text-blue-700">Password Emails Sent</p>
|
||||||
|
<p className="text-4xl font-semibold text-blue-900">{importResults?.password_emails_queued || 0}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-4 justify-between flex-wrap">
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
{importResults?.failed_rows > 0 && (
|
||||||
|
<Button onClick={handleDownloadErrors} variant="outline">
|
||||||
|
<FileDown className="h-4 w-4 mr-2" />
|
||||||
|
Download Error Report
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4 mr-2" />
|
||||||
|
View Imported Members
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rollback button (prominent, red) */}
|
||||||
|
{importResults?.successful_rows > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setRollbackConfirmOpen(true)}
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Rollback Import ({importResults.successful_rows} users)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rollback confirmation dialog */}
|
||||||
|
<Dialog open={rollbackConfirmOpen} onOpenChange={setRollbackConfirmOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<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]">
|
||||||
|
Confirm Rollback
|
||||||
|
</DialogTitle>
|
||||||
|
</div>
|
||||||
|
<DialogDescription className="text-[#664fa3]">
|
||||||
|
This will permanently delete{' '}
|
||||||
|
<strong>{importResults?.successful_rows} users</strong> that were imported.
|
||||||
|
This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 my-4">
|
||||||
|
<p className="text-sm text-red-800 font-medium mb-2">
|
||||||
|
Type "DELETE {importResults?.successful_rows} USERS" to confirm:
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
className="mt-2"
|
||||||
|
placeholder={`DELETE ${importResults?.successful_rows} USERS`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setRollbackConfirmOpen(false)} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleRollback}
|
||||||
|
disabled={confirmText !== `DELETE ${importResults?.successful_rows} USERS`}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Yes, Delete {importResults?.successful_rows} Users
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Render
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-semibold text-[#422268]">
|
||||||
|
WordPress Import Wizard
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-[#664fa3]">
|
||||||
|
Import WordPress users with interactive status review and full rollback capability
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Step indicator */}
|
||||||
|
<div className="flex items-center justify-between mb-6 px-4">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const StepIcon = step.icon;
|
||||||
|
const isCompleted = currentStep > step.number;
|
||||||
|
const isCurrent = currentStep === step.number;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={step.number}>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
w-10 h-10 rounded-full flex items-center justify-center
|
||||||
|
${isCurrent ? 'bg-[#664fa3] text-white' : ''}
|
||||||
|
${isCompleted ? 'bg-green-600 text-white' : ''}
|
||||||
|
${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isCompleted ? (
|
||||||
|
<CheckCircle className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<StepIcon className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs mt-1 ${isCurrent ? 'font-semibold text-[#422268]' : 'text-gray-600'}`}>
|
||||||
|
{step.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div className={`flex-1 h-0.5 mx-2 ${isCompleted ? 'bg-green-600' : 'bg-gray-300'}`} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step content */}
|
||||||
|
<div className="py-6">
|
||||||
|
{renderStepContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation footer */}
|
||||||
|
<DialogFooter className="flex justify-between items-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleBack}
|
||||||
|
disabled={currentStep === 1 || importing}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{currentStep < 5 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={!canProceed()}
|
||||||
|
className="bg-[#664fa3] hover:bg-[#422268]"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4 ml-2" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 6 && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
if (onSuccess) onSuccess();
|
||||||
|
}}
|
||||||
|
className="bg-[#664fa3] hover:bg-[#422268]"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
|||||||
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<label htmlFor="accepts_tos" className="text-sm text-gray-700" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
I agree to the{' '}
|
I agree to the{' '}
|
||||||
<a
|
<a
|
||||||
href="/membership/terms-of-service"
|
href="/become-a-member/terms-of-service"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||||
@@ -95,7 +95,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
|||||||
</a>
|
</a>
|
||||||
{' '}and{' '}
|
{' '}and{' '}
|
||||||
<a
|
<a
|
||||||
href="/membership/privacy-policy"
|
href="become-a-member/privacy-policy"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||||
|
|||||||
@@ -1,31 +1,33 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Tabs = TabsPrimitive.Root
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
"inline-flex h-full items-center justify-center rounded-lg gap-6 p-1 text-muted-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
TabsList.displayName = TabsPrimitive.List.displayName
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
"inline-flex items-center justify-center whitespace-nowrap hover:bg-[#f1eef9] border-2 border-[#664fa3] rounded-2xl px-3 py-1 text-[#664fa3] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
@@ -34,8 +36,9 @@ const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
|||||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const AuthContext = createContext();
|
const AuthContext = createContext();
|
||||||
|
|
||||||
const API_URL = process.env.REACT_APP_BACKEND_URL;
|
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
|
||||||
|
|
||||||
|
// Log environment on module load for debugging
|
||||||
|
console.log('[AuthContext] Module initialized with:', {
|
||||||
|
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
|
||||||
|
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
|
||||||
|
API_URL: API_URL
|
||||||
|
});
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
@@ -54,16 +61,79 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
|
try {
|
||||||
|
console.log('[AuthContext] Starting login request...', {
|
||||||
|
API_URL: API_URL,
|
||||||
|
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
|
||||||
|
fullUrl: `${API_URL}/api/auth/login`
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${API_URL}/api/auth/login`,
|
||||||
|
{ email, password },
|
||||||
|
{
|
||||||
|
timeout: 30000, // 30 second timeout
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[AuthContext] Login response received:', {
|
||||||
|
status: response.status,
|
||||||
|
hasToken: !!response.data?.access_token,
|
||||||
|
hasUser: !!response.data?.user
|
||||||
|
});
|
||||||
|
|
||||||
const { access_token, user: userData } = response.data;
|
const { access_token, user: userData } = response.data;
|
||||||
|
|
||||||
|
// Store token first
|
||||||
localStorage.setItem('token', access_token);
|
localStorage.setItem('token', access_token);
|
||||||
|
console.log('[AuthContext] Token stored in localStorage');
|
||||||
|
|
||||||
|
// Update state
|
||||||
setToken(access_token);
|
setToken(access_token);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
console.log('[AuthContext] User state updated:', {
|
||||||
|
email: userData.email,
|
||||||
|
role: userData.role
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch user permissions
|
// Fetch user permissions (don't let this fail the login)
|
||||||
|
// Use setTimeout to defer permission fetching slightly
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[AuthContext] Fetching permissions...');
|
||||||
await fetchPermissions(access_token);
|
await fetchPermissions(access_token);
|
||||||
|
console.log('[AuthContext] Permissions fetched successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AuthContext] Failed to fetch permissions (non-critical):', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status
|
||||||
|
});
|
||||||
|
// Don't throw - permissions can be fetched later if needed
|
||||||
|
}
|
||||||
|
}, 100); // Small delay to ensure state is settled
|
||||||
|
|
||||||
return userData;
|
return userData;
|
||||||
|
} catch (error) {
|
||||||
|
// Enhanced error logging
|
||||||
|
console.error('[AuthContext] Login failed:', {
|
||||||
|
message: error.message,
|
||||||
|
response: error.response?.data,
|
||||||
|
status: error.response?.status,
|
||||||
|
code: error.code,
|
||||||
|
config: {
|
||||||
|
url: error.config?.url,
|
||||||
|
method: error.config?.method,
|
||||||
|
timeout: error.config?.timeout
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-throw to let Login component handle the error
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
|||||||
@@ -116,3 +116,27 @@ code {
|
|||||||
border-bottom-color: inherit;
|
border-bottom-color: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
@supports selector(::-webkit-scrollbar) {
|
||||||
|
.scrollbar-dashboard::-webkit-scrollbar {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-dashboard::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #ddd8eb;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.scrollbar-x-dashboard::-webkit-scrollbar:horizontal {
|
||||||
|
height: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-x-dashboard::-webkit-scrollbar-thumb:horizontal {
|
||||||
|
background-color: #ddd8eb;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
.hide-scrollbar-x::-webkit-scrollbar:horizontal {
|
||||||
|
height: 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const AcceptInvitation = () => {
|
|||||||
const [invitation, setInvitation] = useState(null);
|
const [invitation, setInvitation] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [successUser, setSuccessUser] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
password: '',
|
password: '',
|
||||||
@@ -134,19 +136,23 @@ const AcceptInvitation = () => {
|
|||||||
const { access_token, user } = response.data;
|
const { access_token, user } = response.data;
|
||||||
localStorage.setItem('token', access_token);
|
localStorage.setItem('token', access_token);
|
||||||
|
|
||||||
toast.success('Welcome to LOAF! Your account has been created successfully.');
|
|
||||||
|
|
||||||
// Call login to update auth context
|
// Call login to update auth context
|
||||||
if (login) {
|
if (login) {
|
||||||
await login(invitation.email, formData.password);
|
await login(invitation.email, formData.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect based on role
|
// Show success state
|
||||||
|
setSuccessUser(user);
|
||||||
|
setSuccess(true);
|
||||||
|
|
||||||
|
// Auto-redirect after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
if (user.role === 'admin' || user.role === 'superadmin') {
|
if (user.role === 'admin' || user.role === 'superadmin') {
|
||||||
navigate('/admin/dashboard');
|
navigate('/admin');
|
||||||
} else {
|
} else {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
}
|
}
|
||||||
|
}, 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
|
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
@@ -206,6 +212,83 @@ const AcceptInvitation = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const redirectPath = successUser?.role === 'admin' || successUser?.role === 'superadmin' ? '/admin' : '/dashboard';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||||
|
{/* Success Animation */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="h-24 w-24 mx-auto rounded-full bg-gradient-to-br from-[#81B29A] to-[#6DA085] flex items-center justify-center animate-bounce">
|
||||||
|
<CheckCircle className="h-12 w-12 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Welcome to LOAF! 🎉
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Your account has been created successfully.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* User Info Card */}
|
||||||
|
<div className="mb-8 p-6 bg-gradient-to-r from-[#DDD8EB] to-[#F9F8FB] rounded-xl">
|
||||||
|
<div className="grid md:grid-cols-2 gap-4 text-left">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Name
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{successUser?.first_name} {successUser?.last_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Email
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{successUser?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Role
|
||||||
|
</p>
|
||||||
|
<div>{getRoleBadge(successUser?.role)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
<Badge className="bg-[#81B29A] text-white px-4 py-2 rounded-full text-sm">
|
||||||
|
{successUser?.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Redirect Info */}
|
||||||
|
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||||
|
<p className="text-sm text-blue-800" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<Loader2 className="h-4 w-4 inline mr-2 animate-spin" />
|
||||||
|
Redirecting you to your dashboard in 3 seconds...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Continue Button */}
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(redirectPath)}
|
||||||
|
className="w-full h-14 rounded-xl bg-gradient-to-r from-[#81B29A] to-[#6DA085] hover:from-[#6DA085] hover:to-[#5A8F72] text-white text-lg font-semibold"
|
||||||
|
>
|
||||||
|
Continue to Dashboard
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||||
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ const BoardOfDirectors = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const boardMembers = [
|
const boardMembers = [
|
||||||
{ name: 'Danita Cole' },
|
{ name: 'Danita Cole', title: 'Director' },
|
||||||
{ name: 'Roxanne Cherico' },
|
{ name: 'Roxanne Cherico', title: 'Director' },
|
||||||
{ name: 'Lucretia Copeland' },
|
{ name: 'Lucretia Copeland', title: 'Director' },
|
||||||
{ name: 'Julie Fischer' }
|
{ name: 'Julie Fischer', title: 'Director' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ const Dashboard = () => {
|
|||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [resendLoading, setResendLoading] = useState(false);
|
const [resendLoading, setResendLoading] = useState(false);
|
||||||
|
const [eventActivity, setEventActivity] = useState(null);
|
||||||
|
const [activityLoading, setActivityLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUpcomingEvents();
|
fetchUpcomingEvents();
|
||||||
|
fetchEventActivity();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchUpcomingEvents = async () => {
|
const fetchUpcomingEvents = async () => {
|
||||||
@@ -32,6 +35,17 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchEventActivity = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/members/event-activity');
|
||||||
|
setEventActivity(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch event activity:', error);
|
||||||
|
} finally {
|
||||||
|
setActivityLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleResendVerification = async () => {
|
const handleResendVerification = async () => {
|
||||||
setResendLoading(true);
|
setResendLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -298,6 +312,156 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Event Activity Section */}
|
||||||
|
<div className="mt-12">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
My Event Activity
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activityLoading ? (
|
||||||
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event activity...</p>
|
||||||
|
) : eventActivity ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-[#DDD8EB]/20 p-4 rounded-lg">
|
||||||
|
<Calendar className="h-8 w-8 text-[#664fa3]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||||
|
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{eventActivity.total_rsvps}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="bg-[#81B29A]/20 p-4 rounded-lg">
|
||||||
|
<CheckCircle className="h-8 w-8 text-[#81B29A]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Events Attended</p>
|
||||||
|
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{eventActivity.total_attended}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming RSVP'd Events */}
|
||||||
|
{eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && (
|
||||||
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Upcoming Events (RSVP'd)
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{eventActivity.upcoming_events.map((event) => (
|
||||||
|
<Link to={`/events/${event.id}`} key={event.id}>
|
||||||
|
<div className="p-4 border border-[#ddd8eb] rounded-xl hover:border-[#664fa3] hover:shadow-md transition-all">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{new Date(event.start_at).toLocaleDateString()} at{' '}
|
||||||
|
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={
|
||||||
|
event.rsvp_status === 'yes' ? 'bg-[#81B29A] text-white' :
|
||||||
|
event.rsvp_status === 'maybe' ? 'bg-orange-100 text-orange-700' :
|
||||||
|
'bg-gray-200 text-gray-700'
|
||||||
|
}>
|
||||||
|
{event.rsvp_status === 'yes' ? 'Going' :
|
||||||
|
event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Past Events & Attendance */}
|
||||||
|
{eventActivity.past_events && eventActivity.past_events.length > 0 && (
|
||||||
|
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Past Events
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{eventActivity.past_events.slice(0, 5).map((event) => (
|
||||||
|
<div key={event.id} className="p-4 border border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{new Date(event.start_at).toLocaleDateString()} at{' '}
|
||||||
|
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<Badge className={event.attended ? 'bg-[#81B29A] text-white' : 'bg-gray-200 text-gray-700'}>
|
||||||
|
{event.attended ? 'Attended' : 'Did not attend'}
|
||||||
|
</Badge>
|
||||||
|
{event.attended && event.attended_at && (
|
||||||
|
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Checked in: {new Date(event.attended_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{eventActivity.past_events.length > 5 && (
|
||||||
|
<p className="text-sm text-center text-[#664fa3] mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Showing 5 of {eventActivity.past_events.length} past events
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Events Message */}
|
||||||
|
{(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) &&
|
||||||
|
(!eventActivity.past_events || eventActivity.past_events.length === 0) && (
|
||||||
|
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
<div className="text-center">
|
||||||
|
<Calendar className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
No Event Activity Yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Browse upcoming events and RSVP to start building your event history!
|
||||||
|
</p>
|
||||||
|
<Link to="/events">
|
||||||
|
<Button className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6">
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
Browse Events
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertCircle className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Failed to load event activity. Please try refreshing the page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const Donate = () => {
|
|||||||
|
|
||||||
{/* Columns */}
|
{/* Columns */}
|
||||||
<div className="py-12">
|
<div className="py-12">
|
||||||
<div className='grid grid-cols-1 items-stretch lg:grid-cols-[2fr_1fr] gap-8 max-h-[450px]'>
|
<div className='grid grid-cols-1 items-stretch lg:grid-cols-[2fr_1fr] gap-8 lg:max-h-[450px]'>
|
||||||
|
|
||||||
{/* Donation Amount Buttons */}
|
{/* Donation Amount Buttons */}
|
||||||
<section className="flex flex-col h-full">
|
<section className="flex flex-col h-full">
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import PublicFooter from '../components/PublicFooter';
|
|||||||
|
|
||||||
const Landing = () => {
|
const Landing = () => {
|
||||||
// LOAF brand assets (local)
|
// LOAF brand assets (local)
|
||||||
const taglineImage = `${process.env.PUBLIC_URL}/tagline-image.png`;
|
const taglineImage = `${process.env.PUBLIC_URL}/web_elements_tagline.png`;
|
||||||
const shootingStar = `${process.env.PUBLIC_URL}/shooting_star_2.png`;
|
const shootingStar = `${process.env.PUBLIC_URL}/shooting_star_2.png`;
|
||||||
const iconMeetGreet = `${process.env.PUBLIC_URL}/icon-meet-greet.png`;
|
const iconMeetGreet = `${process.env.PUBLIC_URL}/icon-meet-greet.png`;
|
||||||
const iconSocials = `${process.env.PUBLIC_URL}/icon-socials.png`;
|
const iconSocials = `${process.env.PUBLIC_URL}/icon-socials.png`;
|
||||||
const iconActive = `${process.env.PUBLIC_URL}/icon-active.png`;
|
const iconActive = `${process.env.PUBLIC_URL}/icon-active.png`;
|
||||||
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
|
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
|
||||||
|
const friendships = `${process.env.PUBLIC_URL}/friendships.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-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">
|
||||||
@@ -75,13 +75,20 @@ const Landing = () => {
|
|||||||
<PublicNavbar />
|
<PublicNavbar />
|
||||||
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="bg-gradient-to-b from-[#48286e] to-[#664fa3] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-20 sm:py-8 md:py-12 lg:py-16 flex flex-col lg:flex-row gap-8 md:gap-12 lg:gap-16 items-center justify-center">
|
<section className="relative bg-gradient-to-b from-[#48286e] to-[#664fa3] py-20 sm:py-8 md:py-12 lg:py-16 flex flex-col lg:flex-row gap-8 md:gap-12 lg:gap-16 items-center justify-center w-full">
|
||||||
<div className="lg:py-20 py-7 flex flex-col gap-6 sm:gap-8 items-center justify-center w-full lg:w-[420px] lg:flex-shrink-0">
|
{/* Friendships background image */}
|
||||||
|
<div className="absolute inset-0 z-0 flex overflow-hidden top-[-32rem] lg:-top-32">
|
||||||
|
<img src={friendships} alt="Friendships" className="lg:max-w-screen opacity-15 max-w-full max-h-full object-contain" />
|
||||||
|
</div>
|
||||||
|
{/* Blur Overlay */}
|
||||||
|
<div className="absolute inset-0 z-[1] bg-white/5 backdrop-blur-xs"></div>
|
||||||
|
{/* Left column Loaf Image */}
|
||||||
|
<div className="relative z-10 lg:py-20 py-7 flex flex-col gap-6 sm:gap-8 items-center justify-center w-full lg:w-[530px] lg:flex-shrink-0">
|
||||||
<div className="flex flex-col gap-6 items-center">
|
<div className="flex flex-col gap-6 items-center">
|
||||||
<img src={heroLoaf} alt="LOAF" className="w-full max-w-xs md:max-w-[334px] h-auto object-contain" />
|
<img src={heroLoaf} alt="LOAF" className="w-full max-w-xs md:max-w-[370px] h-auto object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 items-center justify-center w-full max-w-[339px]">
|
<div className="flex flex-col gap-4 items-center justify-center w-full max-w-[339px]">
|
||||||
<Link to="/register" className="w-full">
|
<Link to="/become-a-member" className="w-full">
|
||||||
<Button style={{ fontFamily: "'Nunito sans', sans-serif" }} className="bg-[#DDD8EB] hover:bg-white text-[#422268] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-medium w-full transition-colors">
|
<Button style={{ fontFamily: "'Nunito sans', sans-serif" }} className="bg-[#DDD8EB] hover:bg-white text-[#422268] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-medium w-full transition-colors">
|
||||||
Become a Member
|
Become a Member
|
||||||
</Button>
|
</Button>
|
||||||
@@ -91,8 +98,9 @@ const Landing = () => {
|
|||||||
LOAF is supported by the Hollyfield Foundation
|
LOAF is supported by the Hollyfield Foundation
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-8 md:py-12 lg:py-16 flex items-center justify-center w-full lg:w-[594px] h-auto">
|
{/* Right Column Loaf Tagline */}
|
||||||
<img src={taglineImage} alt="LOAF Tagline" className="w-full max-w-[330px] md:max-w-[483px] h-auto object-contain" />
|
<div className="relative z-10 py-8 md:py-12 lg:py-16 flex items-center justify-center w-full lg:max-w-[815px] h-auto">
|
||||||
|
<img src={taglineImage} alt="LOAF Tagline" className="relative z-10 w-full h-auto object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
81
src/pages/NotFound.js
Normal file
81
src/pages/NotFound.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Button } from '../components/ui/button';
|
||||||
|
import { Card } from '../components/ui/card';
|
||||||
|
import { Home, ArrowLeft, Search } from 'lucide-react';
|
||||||
|
|
||||||
|
const NotFound = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||||
|
{/* 404 Illustration */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="relative">
|
||||||
|
<h1
|
||||||
|
className="text-[180px] font-bold text-transparent bg-clip-text bg-gradient-to-br from-[#ddd8eb] to-[#f9f8fb] leading-none"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
404
|
||||||
|
</h1>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Search className="h-24 w-24 text-[#664fa3] opacity-30" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
<h2
|
||||||
|
className="text-3xl font-semibold text-[#422268] mb-4"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Page Not Found
|
||||||
|
</h2>
|
||||||
|
<p
|
||||||
|
className="text-lg text-[#664fa3] mb-8 max-w-md mx-auto"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
variant="outline"
|
||||||
|
className="rounded-xl border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f9f8fb] px-6 py-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="rounded-xl bg-gradient-to-r from-[#664fa3] to-[#422268] hover:from-[#422268] hover:to-[#664fa3] text-white px-6 py-6"
|
||||||
|
>
|
||||||
|
<Home className="h-5 w-5 mr-2" />
|
||||||
|
Back to Home
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
|
||||||
|
<p
|
||||||
|
className="text-sm text-[#664fa3]"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
Need help? Contact us at{' '}
|
||||||
|
<a
|
||||||
|
href="mailto:support@loaftx.org"
|
||||||
|
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||||
|
>
|
||||||
|
support@loaftx.org
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotFound;
|
||||||
@@ -253,7 +253,13 @@ const Plans = () => {
|
|||||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||||
</div>
|
</div>
|
||||||
) : plans.length > 0 ? (
|
) : plans.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
|
<div className={`grid gap-6 sm:gap-8 mx-auto ${
|
||||||
|
plans.length === 1
|
||||||
|
? 'grid-cols-1 max-w-md'
|
||||||
|
: plans.length === 2
|
||||||
|
? 'grid-cols-1 sm:grid-cols-2 max-w-3xl'
|
||||||
|
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl'
|
||||||
|
}`}>
|
||||||
{plans.map((plan) => {
|
{plans.map((plan) => {
|
||||||
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
||||||
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } 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,7 +9,8 @@ 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 } from 'lucide-react';
|
import { User, Save, Lock, Heart, Users, Mail, BookUser, Camera, Upload, Trash2 } 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 = () => {
|
||||||
@@ -17,6 +18,12 @@ 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: '',
|
||||||
@@ -49,13 +56,27 @@ 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 || '',
|
||||||
@@ -124,6 +145,57 @@ 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);
|
||||||
@@ -205,6 +277,59 @@ 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" }}>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import React from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import PublicNavbar from '../components/PublicNavbar';
|
import PublicNavbar from '../components/PublicNavbar';
|
||||||
import PublicFooter from '../components/PublicFooter';
|
import PublicFooter from '../components/PublicFooter';
|
||||||
import ReactMarkdown from "react-markdown";
|
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
|
|
||||||
|
|
||||||
export default function TermsOfService() {
|
export default function TermsOfService() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
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';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminBylaws = () => {
|
const AdminBylaws = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [bylaws, setBylaws] = useState([]);
|
const [bylaws, setBylaws] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -44,7 +46,7 @@ const AdminBylaws = () => {
|
|||||||
version: '',
|
version: '',
|
||||||
effective_date: '',
|
effective_date: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive',
|
document_type: 'link',
|
||||||
is_current: false
|
is_current: false
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -71,9 +73,10 @@ const AdminBylaws = () => {
|
|||||||
version: '',
|
version: '',
|
||||||
effective_date: new Date().toISOString().split('T')[0],
|
effective_date: new Date().toISOString().split('T')[0],
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive',
|
document_type: 'link',
|
||||||
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
|
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
|
||||||
});
|
});
|
||||||
|
setUploadedFile(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,6 +186,7 @@ const AdminBylaws = () => {
|
|||||||
Manage LOAF governing bylaws and version history
|
Manage LOAF governing bylaws and version history
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{hasPermission('bylaws.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
@@ -190,6 +194,7 @@ const AdminBylaws = () => {
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Version
|
Add Version
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Bylaws */}
|
{/* Current Bylaws */}
|
||||||
@@ -225,6 +230,7 @@ const AdminBylaws = () => {
|
|||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<ExternalLink className="h-4 w-4 mr-1" />
|
||||||
View
|
View
|
||||||
</Button>
|
</Button>
|
||||||
|
{hasPermission('bylaws.edit') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -233,6 +239,8 @@ const AdminBylaws = () => {
|
|||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('bylaws.delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -241,22 +249,25 @@ const AdminBylaws = () => {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
||||||
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
|
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>Document Type: <strong>{currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}</strong></span>
|
<span>Document Type: <strong>{currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="p-12 text-center">
|
<Card className="p-12 text-center">
|
||||||
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
|
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
|
||||||
|
{hasPermission('bylaws.create') && (
|
||||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create Bylaws
|
Create Bylaws
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -289,6 +300,7 @@ const AdminBylaws = () => {
|
|||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
{hasPermission('bylaws.edit') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -297,6 +309,8 @@ const AdminBylaws = () => {
|
|||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('bylaws.delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -305,6 +319,7 @@ const AdminBylaws = () => {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -363,14 +378,16 @@ const AdminBylaws = () => {
|
|||||||
<Label htmlFor="document_type">Document Type *</Label>
|
<Label htmlFor="document_type">Document Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.document_type}
|
value={formData.document_type}
|
||||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
onValueChange={(value) => {
|
||||||
|
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||||
|
setUploadedFile(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="upload">Upload</SelectItem>
|
<SelectItem value="upload">Upload</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -391,6 +408,11 @@ const AdminBylaws = () => {
|
|||||||
Selected: {uploadedFile.name}
|
Selected: {uploadedFile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{selectedBylaws && !uploadedFile && (
|
||||||
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
|
Current file will be kept if no new file is selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -399,12 +421,11 @@ const AdminBylaws = () => {
|
|||||||
id="document_url"
|
id="document_url"
|
||||||
value={formData.document_url}
|
value={formData.document_url}
|
||||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||||
placeholder="https://drive.google.com/file/d/..."
|
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-[#664fa3] mt-1">
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react';
|
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle,Globe } from 'lucide-react';
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
const AdminDashboard = () => {
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
@@ -56,6 +56,7 @@ const AdminDashboard = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className='flex justify-between items-center'>
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
Admin Dashboard
|
Admin Dashboard
|
||||||
@@ -64,6 +65,15 @@ const AdminDashboard = () => {
|
|||||||
Manage users, events, and membership applications.
|
Manage users, events, and membership applications.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to={'/'}>
|
||||||
|
<Button
|
||||||
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Globe />
|
||||||
|
View Public Site
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||||
|
|||||||
476
src/pages/admin/AdminDonations.js
Normal file
476
src/pages/admin/AdminDonations.js
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
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 { hasPermission } = useAuth();
|
||||||
|
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>
|
||||||
|
{hasPermission('donations.export') && (
|
||||||
|
<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;
|
||||||
548
src/pages/admin/AdminEventAttendance.js
Normal file
548
src/pages/admin/AdminEventAttendance.js
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import api from '../../utils/api';
|
||||||
|
import { Card } from '../../components/ui/card';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { Badge } from '../../components/ui/badge';
|
||||||
|
import { Checkbox } from '../../components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
MapPin,
|
||||||
|
Download,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
UserCheck,
|
||||||
|
UserX,
|
||||||
|
HelpCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import moment from 'moment';
|
||||||
|
|
||||||
|
const AdminEventAttendance = () => {
|
||||||
|
const { eventId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [event, setEvent] = useState(null);
|
||||||
|
const [rsvps, setRsvps] = useState([]);
|
||||||
|
const [filteredRsvps, setFilteredRsvps] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// Filters and search
|
||||||
|
const [activeTab, setActiveTab] = useState('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Bulk selection
|
||||||
|
const [selectedRsvps, setSelectedRsvps] = useState(new Set());
|
||||||
|
const [selectAll, setSelectAll] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEventAndRsvps();
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterRsvps();
|
||||||
|
}, [rsvps, activeTab, searchQuery]);
|
||||||
|
|
||||||
|
const fetchEventAndRsvps = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [eventRes, rsvpsRes] = await Promise.all([
|
||||||
|
api.get(`/admin/events/${eventId}`),
|
||||||
|
api.get(`/admin/events/${eventId}/rsvps`)
|
||||||
|
]);
|
||||||
|
setEvent(eventRes.data);
|
||||||
|
setRsvps(rsvpsRes.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch event data:', error);
|
||||||
|
toast.error('Failed to load event data');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterRsvps = () => {
|
||||||
|
let filtered = [...rsvps];
|
||||||
|
|
||||||
|
// Filter by RSVP status tab
|
||||||
|
if (activeTab !== 'all') {
|
||||||
|
filtered = filtered.filter(rsvp => rsvp.rsvp_status === activeTab);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
filtered = filtered.filter(rsvp =>
|
||||||
|
rsvp.user_name?.toLowerCase().includes(query) ||
|
||||||
|
rsvp.user_email?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredRsvps(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectAll) {
|
||||||
|
setSelectedRsvps(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedRsvps(new Set(filteredRsvps.map(rsvp => rsvp.user_id)));
|
||||||
|
}
|
||||||
|
setSelectAll(!selectAll);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectRsvp = (userId) => {
|
||||||
|
const newSelected = new Set(selectedRsvps);
|
||||||
|
if (newSelected.has(userId)) {
|
||||||
|
newSelected.delete(userId);
|
||||||
|
} else {
|
||||||
|
newSelected.add(userId);
|
||||||
|
}
|
||||||
|
setSelectedRsvps(newSelected);
|
||||||
|
setSelectAll(newSelected.size === filteredRsvps.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAttendance = async (attended) => {
|
||||||
|
if (selectedRsvps.size === 0) {
|
||||||
|
toast.error('Please select at least one RSVP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const updates = Array.from(selectedRsvps).map(userId => ({
|
||||||
|
user_id: userId,
|
||||||
|
attended
|
||||||
|
}));
|
||||||
|
|
||||||
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||||
|
|
||||||
|
toast.success(`Marked ${selectedRsvps.size} ${selectedRsvps.size === 1 ? 'person' : 'people'} as ${attended ? 'attended' : 'not attended'}`);
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
await fetchEventAndRsvps();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
setSelectedRsvps(new Set());
|
||||||
|
setSelectAll(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update attendance:', error);
|
||||||
|
toast.error('Failed to update attendance');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndividualAttendance = async (userId, attended) => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const updates = [{
|
||||||
|
user_id: userId,
|
||||||
|
attended
|
||||||
|
}];
|
||||||
|
|
||||||
|
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||||
|
|
||||||
|
toast.success(`Attendance ${attended ? 'confirmed' : 'removed'}`);
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
await fetchEventAndRsvps();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update attendance:', error);
|
||||||
|
toast.error('Failed to update attendance');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportToCSV = () => {
|
||||||
|
if (filteredRsvps.length === 0) {
|
||||||
|
toast.error('No RSVPs to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV header
|
||||||
|
const headers = ['Name', 'Email', 'RSVP Status', 'Attended', 'Attended At'];
|
||||||
|
|
||||||
|
// CSV rows
|
||||||
|
const rows = filteredRsvps.map(rsvp => [
|
||||||
|
`"${rsvp.user_name}"`,
|
||||||
|
`"${rsvp.user_email}"`,
|
||||||
|
`"${rsvp.rsvp_status.toUpperCase()}"`,
|
||||||
|
rsvp.attended ? 'Yes' : 'No',
|
||||||
|
rsvp.attended_at ? `"${moment(rsvp.attended_at).format('YYYY-MM-DD HH:mm A')}"` : ''
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Combine headers and rows
|
||||||
|
const csvContent = [
|
||||||
|
headers.join(','),
|
||||||
|
...rows.map(row => row.join(','))
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
// Create blob and download
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', `${event?.title.replace(/\s+/g, '_')}_RSVPs_${moment().format('YYYY-MM-DD')}.csv`);
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
toast.success('CSV exported successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStats = () => {
|
||||||
|
const total = rsvps.length;
|
||||||
|
const yesCount = rsvps.filter(r => r.rsvp_status === 'yes').length;
|
||||||
|
const noCount = rsvps.filter(r => r.rsvp_status === 'no').length;
|
||||||
|
const maybeCount = rsvps.filter(r => r.rsvp_status === 'maybe').length;
|
||||||
|
const attendedCount = rsvps.filter(r => r.attended).length;
|
||||||
|
|
||||||
|
return { total, yesCount, noCount, maybeCount, attendedCount };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = getStats();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-[#664fa3]">Loading event data...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-[#664fa3] mb-4">Event not found</p>
|
||||||
|
<Button onClick={() => navigate('/admin/events')} className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl">
|
||||||
|
Back to Events
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/admin/events')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
|
Back to Events
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Event Attendance
|
||||||
|
</h1>
|
||||||
|
<p className="text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Manage RSVPs and track attendance for this event
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={exportToCSV}
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
Export to CSV
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event Details Card */}
|
||||||
|
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{event.title}
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-4 text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span>
|
||||||
|
</div>
|
||||||
|
{event.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
<span>{event.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={`${event.published ? 'bg-[#81B29A]' : 'bg-[#ddd8eb]'} text-white px-3 py-1`}>
|
||||||
|
{event.published ? 'Published' : 'Draft'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="h-8 w-8 text-[#664fa3]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserCheck className="h-8 w-8 text-[#81B29A]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserX className="h-8 w-8 text-[#E07A5F]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<HelpCircle className="h-8 w-8 text-[#F2CC8F]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Check className="h-8 w-8 text-[#664fa3]" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
|
||||||
|
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters and Actions */}
|
||||||
|
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Tab Filters */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
variant={activeTab === 'all' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'bg-[#664fa3] hover:bg-[#422268] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
All ({stats.total})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('yes')}
|
||||||
|
variant={activeTab === 'yes' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'yes'
|
||||||
|
? 'bg-[#81B29A] hover:bg-[#6a9a83] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Yes ({stats.yesCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('no')}
|
||||||
|
variant={activeTab === 'no' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'no'
|
||||||
|
? 'bg-[#E07A5F] hover:bg-[#d16b54] text-white'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
No ({stats.noCount})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('maybe')}
|
||||||
|
variant={activeTab === 'maybe' ? 'default' : 'outline'}
|
||||||
|
className={`rounded-xl ${
|
||||||
|
activeTab === 'maybe'
|
||||||
|
? 'bg-[#F2CC8F] hover:bg-[#e8bf7a] text-[#422268]'
|
||||||
|
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||||
|
}`}
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
Maybe ({stats.maybeCount})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search and Bulk Actions */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-[200px] max-w-md relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#664fa3]" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10 border-[#ddd8eb] rounded-xl"
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedRsvps.size > 0 && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge className="bg-[#664fa3] text-white px-3 py-1">
|
||||||
|
{selectedRsvps.size} selected
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleBulkAttendance(true)}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4 mr-1" />
|
||||||
|
Mark Attended
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleBulkAttendance(false)}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-[#E07A5F] hover:bg-[#d16b54] text-white rounded-xl"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Mark Not Attended
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RSVP Table */}
|
||||||
|
<Card className="bg-white border-[#ddd8eb] rounded-xl overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-[#F8F7FB] border-b border-[#ddd8eb]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectAll}
|
||||||
|
onCheckedChange={handleSelectAll}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Email
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
RSVP Status
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-center text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Attendance
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
Attended At
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredRsvps.length > 0 ? (
|
||||||
|
filteredRsvps.map((rsvp) => (
|
||||||
|
<tr key={rsvp.user_id} className="border-b border-[#ddd8eb] hover:bg-[#F8F7FB] transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRsvps.has(rsvp.user_id)}
|
||||||
|
onCheckedChange={() => handleSelectRsvp(rsvp.user_id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.user_name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.user_email}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge
|
||||||
|
className={`${
|
||||||
|
rsvp.rsvp_status === 'yes'
|
||||||
|
? 'bg-[#81B29A]'
|
||||||
|
: rsvp.rsvp_status === 'no'
|
||||||
|
? 'bg-[#E07A5F]'
|
||||||
|
: 'bg-[#F2CC8F] text-[#422268]'
|
||||||
|
} text-white text-xs px-2 py-1`}
|
||||||
|
>
|
||||||
|
{rsvp.rsvp_status.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
{rsvp.attended ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-lg min-w-[120px]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Attended
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleIndividualAttendance(rsvp.user_id, true)}
|
||||||
|
disabled={saving}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#81B29A] hover:text-white hover:border-[#81B29A] rounded-lg min-w-[120px]"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 mr-1" />
|
||||||
|
Not Attended
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" className="px-4 py-12 text-center">
|
||||||
|
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
{searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminEventAttendance;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -8,16 +9,14 @@ import { Input } from '../../components/ui/input';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
|
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||||
import { AttendanceDialog } from '../../components/AttendanceDialog';
|
|
||||||
|
|
||||||
const AdminEvents = () => {
|
const AdminEvents = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [editingEvent, setEditingEvent] = useState(null);
|
const [editingEvent, setEditingEvent] = useState(null);
|
||||||
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
@@ -342,19 +341,16 @@ const AdminEvents = () => {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
|
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
|
||||||
{/* Mark Attendance Button */}
|
{/* Manage Attendance Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
|
||||||
setSelectedEvent(event);
|
|
||||||
setAttendanceDialogOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
||||||
data-testid={`mark-attendance-${event.id}`}
|
data-testid={`mark-attendance-${event.id}`}
|
||||||
>
|
>
|
||||||
<Users className="h-4 w-4 mr-2" />
|
<Users className="h-4 w-4 mr-2" />
|
||||||
Mark Attendance ({event.rsvp_count || 0} RSVPs)
|
Manage Attendance ({event.rsvp_count || 0} RSVPs)
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Other Actions */}
|
{/* Other Actions */}
|
||||||
@@ -419,14 +415,6 @@ const AdminEvents = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attendance Dialog */}
|
|
||||||
<AttendanceDialog
|
|
||||||
event={selectedEvent}
|
|
||||||
open={attendanceDialogOpen}
|
|
||||||
onOpenChange={setAttendanceDialogOpen}
|
|
||||||
onSuccess={fetchEvents}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
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';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminFinancials = () => {
|
const AdminFinancials = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [reports, setReports] = useState([]);
|
const [reports, setReports] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -42,7 +44,7 @@ const AdminFinancials = () => {
|
|||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
title: '',
|
title: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -67,8 +69,9 @@ const AdminFinancials = () => {
|
|||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
title: '',
|
title: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_drive'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
|
setUploadedFile(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,6 +164,7 @@ const AdminFinancials = () => {
|
|||||||
Manage annual financial reports
|
Manage annual financial reports
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{hasPermission('financials.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
@@ -168,6 +172,7 @@ const AdminFinancials = () => {
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Report
|
Add Report
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reports List */}
|
{/* Reports List */}
|
||||||
@@ -175,10 +180,12 @@ const AdminFinancials = () => {
|
|||||||
<Card className="p-12 text-center">
|
<Card className="p-12 text-center">
|
||||||
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
|
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
|
||||||
|
{hasPermission('financials.create') && (
|
||||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create First Report
|
Create First Report
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -208,7 +215,9 @@ const AdminFinancials = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{(hasPermission('financials.edit') || hasPermission('financials.delete')) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{hasPermission('financials.edit') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -217,6 +226,8 @@ const AdminFinancials = () => {
|
|||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('financials.delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -225,7 +236,9 @@ const AdminFinancials = () => {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -274,14 +287,16 @@ const AdminFinancials = () => {
|
|||||||
<Label htmlFor="document_type">Document Type *</Label>
|
<Label htmlFor="document_type">Document Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.document_type}
|
value={formData.document_type}
|
||||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
onValueChange={(value) => {
|
||||||
|
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||||
|
setUploadedFile(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="upload">Upload</SelectItem>
|
<SelectItem value="upload">Upload</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -302,6 +317,11 @@ const AdminFinancials = () => {
|
|||||||
Selected: {uploadedFile.name}
|
Selected: {uploadedFile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{selectedReport && !uploadedFile && (
|
||||||
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
|
Current file will be kept if no new file is selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -310,12 +330,11 @@ const AdminFinancials = () => {
|
|||||||
id="document_url"
|
id="document_url"
|
||||||
value={formData.document_url}
|
value={formData.document_url}
|
||||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||||
placeholder="https://drive.google.com/file/d/..."
|
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-[#664fa3] mt-1">
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
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';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -14,11 +16,12 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '../../components/ui/select';
|
} from '../../components/ui/select';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
||||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
|
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
|
||||||
const AdminGallery = () => {
|
const AdminGallery = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [events, setEvents] = useState([]);
|
const [events, setEvents] = useState([]);
|
||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||||
const [galleryImages, setGalleryImages] = useState([]);
|
const [galleryImages, setGalleryImages] = useState([]);
|
||||||
@@ -179,7 +182,33 @@ const AdminGallery = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedEvent && (
|
{/* Empty State Message */}
|
||||||
|
{events.length === 0 && (
|
||||||
|
<div className="mt-4 p-4 bg-[#f1eef9] border-2 border-[#DDD8EB] rounded-xl">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="text-sm font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
No Events Available
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-[#664fa3] mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
You need to create an event before uploading gallery images. Events help organize photos by occasion.
|
||||||
|
</p>
|
||||||
|
<Link to="/admin/events">
|
||||||
|
<Button
|
||||||
|
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl text-sm"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
Create Your First Event
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedEvent && hasPermission('gallery.upload') && (
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -240,7 +269,9 @@ const AdminGallery = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overlay with Actions */}
|
{/* Overlay with Actions */}
|
||||||
|
{(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
|
||||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
||||||
|
{hasPermission('gallery.edit') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => openEditCaption(image)}
|
onClick={() => openEditCaption(image)}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -250,6 +281,8 @@ const AdminGallery = () => {
|
|||||||
<Edit className="h-4 w-4 mr-1" />
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
Caption
|
Caption
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('gallery.delete') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleDeleteImage(image.id)}
|
onClick={() => handleDeleteImage(image.id)}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -259,7 +292,9 @@ const AdminGallery = () => {
|
|||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Caption Preview */}
|
{/* Caption Preview */}
|
||||||
{image.caption && (
|
{image.caption && (
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
|||||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||||
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
||||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||||
import ImportMembersDialog from '../../components/ImportMembersDialog';
|
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
||||||
|
|
||||||
const AdminMembers = () => {
|
const AdminMembers = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -569,7 +569,7 @@ const AdminMembers = () => {
|
|||||||
onSuccess={fetchMembers}
|
onSuccess={fetchMembers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ImportMembersDialog
|
<WordPressImportWizard
|
||||||
open={importDialogOpen}
|
open={importDialogOpen}
|
||||||
onOpenChange={setImportDialogOpen}
|
onOpenChange={setImportDialogOpen}
|
||||||
onSuccess={fetchMembers}
|
onSuccess={fetchMembers}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
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';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminNewsletters = () => {
|
const AdminNewsletters = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [newsletters, setNewsletters] = useState([]);
|
const [newsletters, setNewsletters] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
@@ -44,7 +46,7 @@ const AdminNewsletters = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
published_date: '',
|
published_date: '',
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_docs'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -70,8 +72,9 @@ const AdminNewsletters = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
published_date: new Date().toISOString().split('T')[0],
|
published_date: new Date().toISOString().split('T')[0],
|
||||||
document_url: '',
|
document_url: '',
|
||||||
document_type: 'google_docs'
|
document_type: 'link'
|
||||||
});
|
});
|
||||||
|
setUploadedFile(null);
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +192,7 @@ const AdminNewsletters = () => {
|
|||||||
Create and manage newsletter archive
|
Create and manage newsletter archive
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{hasPermission('newsletters.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||||
@@ -196,6 +200,7 @@ const AdminNewsletters = () => {
|
|||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Add Newsletter
|
Add Newsletter
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Newsletters List */}
|
{/* Newsletters List */}
|
||||||
@@ -203,10 +208,12 @@ const AdminNewsletters = () => {
|
|||||||
<Card className="p-12 text-center">
|
<Card className="p-12 text-center">
|
||||||
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||||
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
|
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
|
||||||
|
{hasPermission('newsletters.create') && (
|
||||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create First Newsletter
|
Create First Newsletter
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -232,7 +239,7 @@ const AdminNewsletters = () => {
|
|||||||
{formatDate(newsletter.published_date)}
|
{formatDate(newsletter.published_date)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||||
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
|
{newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -245,7 +252,9 @@ const AdminNewsletters = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{(hasPermission('newsletters.edit') || hasPermission('newsletters.delete')) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
{hasPermission('newsletters.edit') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -254,6 +263,8 @@ const AdminNewsletters = () => {
|
|||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('newsletters.delete') && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -262,7 +273,9 @@ const AdminNewsletters = () => {
|
|||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -322,14 +335,16 @@ const AdminNewsletters = () => {
|
|||||||
<Label htmlFor="document_type">Document Type *</Label>
|
<Label htmlFor="document_type">Document Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.document_type}
|
value={formData.document_type}
|
||||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
onValueChange={(value) => {
|
||||||
|
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||||
|
setUploadedFile(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="google_docs">Google Docs</SelectItem>
|
<SelectItem value="link">Link</SelectItem>
|
||||||
<SelectItem value="pdf">PDF</SelectItem>
|
|
||||||
<SelectItem value="upload">Upload</SelectItem>
|
<SelectItem value="upload">Upload</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -350,6 +365,11 @@ const AdminNewsletters = () => {
|
|||||||
Selected: {uploadedFile.name}
|
Selected: {uploadedFile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{selectedNewsletter && !uploadedFile && (
|
||||||
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
|
Current file will be kept if no new file is selected
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
@@ -358,12 +378,11 @@ const AdminNewsletters = () => {
|
|||||||
id="document_url"
|
id="document_url"
|
||||||
value={formData.document_url}
|
value={formData.document_url}
|
||||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||||
placeholder="https://docs.google.com/document/d/..."
|
placeholder="https://docs.google.com/document/d/... or https://example.com/file.pdf"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-[#664fa3] mt-1">
|
<p className="text-sm text-[#664fa3] mt-1">
|
||||||
{formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'}
|
Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.)
|
||||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
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';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const AdminPlans = () => {
|
const AdminPlans = () => {
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const [plans, setPlans] = useState([]);
|
const [plans, setPlans] = useState([]);
|
||||||
const [filteredPlans, setFilteredPlans] = useState([]);
|
const [filteredPlans, setFilteredPlans] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -136,6 +138,7 @@ const AdminPlans = () => {
|
|||||||
Manage membership plans and pricing.
|
Manage membership plans and pricing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{hasPermission('subscriptions.plans') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreatePlan}
|
onClick={handleCreatePlan}
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||||
@@ -143,6 +146,7 @@ const AdminPlans = () => {
|
|||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Create Plan
|
Create Plan
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -286,6 +290,7 @@ const AdminPlans = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{hasPermission('subscriptions.plans') && (
|
||||||
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
|
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleEditPlan(plan)}
|
onClick={() => handleEditPlan(plan)}
|
||||||
@@ -307,6 +312,7 @@ const AdminPlans = () => {
|
|||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Warning for plans with subscribers */}
|
{/* Warning for plans with subscribers */}
|
||||||
{plan.subscriber_count > 0 && (
|
{plan.subscriber_count > 0 && (
|
||||||
@@ -328,7 +334,7 @@ const AdminPlans = () => {
|
|||||||
? 'Try adjusting your filters'
|
? 'Try adjusting your filters'
|
||||||
: 'Create your first subscription plan to get started'}
|
: 'Create your first subscription plan to get started'}
|
||||||
</p>
|
</p>
|
||||||
{!searchQuery && activeFilter === 'all' && (
|
{!searchQuery && activeFilter === 'all' && hasPermission('subscriptions.plans') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreatePlan}
|
onClick={handleCreatePlan}
|
||||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
@@ -11,10 +12,11 @@ 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 } from 'lucide-react';
|
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||||
|
|
||||||
const AdminStaff = () => {
|
const AdminStaff = () => {
|
||||||
const { hasPermission } = useAuth();
|
const navigate = useNavigate();
|
||||||
|
const { hasPermission, user } = useAuth();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -69,6 +71,32 @@ const AdminStaff = () => {
|
|||||||
setFilteredUsers(filtered);
|
setFilteredUsers(filtered);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (userId, currentStatus) => {
|
||||||
|
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||||
|
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
|
||||||
|
fetchStaff(); // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to update user status');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId, userName) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete ${userName}? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/users/${userId}`);
|
||||||
|
toast.success('User deleted successfully');
|
||||||
|
fetchStaff(); // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to delete user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getRoleBadge = (role) => {
|
const getRoleBadge = (role) => {
|
||||||
const config = {
|
const config = {
|
||||||
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
||||||
@@ -114,7 +142,7 @@ const AdminStaff = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{hasPermission('users.invite') && (
|
{hasPermission('users.create') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setInviteDialogOpen(true)}
|
onClick={() => setInviteDialogOpen(true)}
|
||||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
||||||
@@ -246,6 +274,53 @@ const AdminStaff = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{hasPermission('users.status') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleToggleStatus(user.id, user.status)}
|
||||||
|
variant="outline"
|
||||||
|
className={`border-2 rounded-full px-4 py-2 ${
|
||||||
|
user.status === 'active'
|
||||||
|
? 'border-orange-500 text-orange-600 hover:bg-orange-50'
|
||||||
|
: 'border-green-500 text-green-600 hover:bg-green-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.status === 'active' ? (
|
||||||
|
<>
|
||||||
|
<UserX className="h-4 w-4 mr-2" />
|
||||||
|
Deactivate
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserCheck className="h-4 w-4 mr-2" />
|
||||||
|
Activate
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPermission('users.delete') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||||
|
variant="outline"
|
||||||
|
className="border-2 border-red-500 text-red-600 hover:bg-red-50 rounded-full px-4 py-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../../context/AuthContext';
|
||||||
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 { Input } from '../../components/ui/input';
|
import { Input } from '../../components/ui/input';
|
||||||
@@ -30,10 +31,21 @@ 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 { hasPermission } = useAuth();
|
||||||
const [subscriptions, setSubscriptions] = useState([]);
|
const [subscriptions, setSubscriptions] = useState([]);
|
||||||
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
|
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
|
||||||
const [plans, setPlans] = useState([]);
|
const [plans, setPlans] = useState([]);
|
||||||
@@ -42,6 +54,7 @@ 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);
|
||||||
@@ -118,6 +131,62 @@ 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}`, {
|
||||||
@@ -151,6 +220,38 @@ const AdminSubscriptions = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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)}`;
|
||||||
};
|
};
|
||||||
@@ -307,9 +408,42 @@ const AdminSubscriptions = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{/* Export Dropdown */}
|
||||||
|
{hasPermission('subscriptions.export') && (
|
||||||
|
<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>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Subscriptions Table */}
|
{/* Subscriptions Table */}
|
||||||
@@ -373,6 +507,7 @@ const AdminSubscriptions = () => {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-2">
|
<div className="flex gap-2 pt-2">
|
||||||
|
{hasPermission('subscriptions.edit') && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -382,7 +517,8 @@ const AdminSubscriptions = () => {
|
|||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
{sub.status === 'active' && (
|
)}
|
||||||
|
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -477,6 +613,7 @@ const AdminSubscriptions = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{hasPermission('subscriptions.edit') && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -485,7 +622,8 @@ const AdminSubscriptions = () => {
|
|||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{sub.status === 'active' && (
|
)}
|
||||||
|
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -542,6 +680,59 @@ const AdminSubscriptions = () => {
|
|||||||
<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,10 +1,11 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef } 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 { ArrowLeft, Mail, Phone, MapPin, Calendar, Lock, AlertTriangle } from 'lucide-react';
|
import { Avatar, AvatarImage, AvatarFallback } from '../../components/ui/avatar';
|
||||||
|
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';
|
||||||
|
|
||||||
@@ -19,8 +20,13 @@ 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]);
|
||||||
@@ -48,6 +54,80 @@ 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);
|
||||||
@@ -141,9 +221,12 @@ 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 */}
|
||||||
<div className="h-24 w-24 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold text-3xl">
|
<Avatar className="h-24 w-24 border-4 border-[#ddd8eb]">
|
||||||
|
<AvatarImage src={user.profile_photo_url} alt={`${user.first_name} ${user.last_name}`} />
|
||||||
|
<AvatarFallback className="bg-[#DDD8EB] text-[#422268] font-semibold text-3xl">
|
||||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||||
</div>
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
{/* User Info */}
|
{/* User Info */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -207,6 +290,37 @@ 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>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
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';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
@@ -29,11 +30,13 @@ 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 } from 'lucide-react';
|
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X } 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 { hasPermission } = useAuth();
|
||||||
const [pendingUsers, setPendingUsers] = useState([]);
|
const [pendingUsers, setPendingUsers] = useState([]);
|
||||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -42,6 +45,8 @@ 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('');
|
||||||
@@ -71,7 +76,7 @@ const AdminValidations = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/admin/users');
|
const response = await api.get('/admin/users');
|
||||||
const pending = response.data.filter(user =>
|
const pending = response.data.filter(user =>
|
||||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(user.status)
|
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending', 'rejected'].includes(user.status)
|
||||||
);
|
);
|
||||||
setPendingUsers(pending);
|
setPendingUsers(pending);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -193,12 +198,50 @@ 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 handleReactivateUser = async (user) => {
|
||||||
|
setActionLoading(user.id);
|
||||||
|
try {
|
||||||
|
await api.put(`/admin/users/${user.id}/status`, {
|
||||||
|
status: 'pending_validation'
|
||||||
|
});
|
||||||
|
toast.success(`${user.first_name} ${user.last_name} has been reactivated and moved to pending validation`);
|
||||||
|
fetchPendingUsers();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.response?.data?.detail || 'Failed to reactivate 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' },
|
||||||
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||||
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' }
|
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||||
|
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = config[status];
|
const statusConfig = config[status];
|
||||||
@@ -277,6 +320,12 @@ const AdminValidations = () => {
|
|||||||
{pendingUsers.filter(u => u.status === 'payment_pending').length}
|
{pendingUsers.filter(u => u.status === 'payment_pending').length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p>
|
||||||
|
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||||
|
{pendingUsers.filter(u => u.status === 'rejected').length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -302,6 +351,8 @@ const AdminValidations = () => {
|
|||||||
<SelectItem value="pending_email">Awaiting Email</SelectItem>
|
<SelectItem value="pending_email">Awaiting Email</SelectItem>
|
||||||
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
||||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||||
|
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||||
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,7 +410,18 @@ const AdminValidations = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{user.status === 'pending_email' ? (
|
{user.status === 'rejected' ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => handleReactivateUser(user)}
|
||||||
|
disabled={actionLoading === user.id}
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||||
|
>
|
||||||
|
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'}
|
||||||
|
</Button>
|
||||||
|
) : user.status === 'pending_email' ? (
|
||||||
|
<>
|
||||||
|
{hasPermission('users.approve') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleBypassAndValidateRequest(user)}
|
onClick={() => handleBypassAndValidateRequest(user)}
|
||||||
disabled={actionLoading === user.id}
|
disabled={actionLoading === user.id}
|
||||||
@@ -368,7 +430,23 @@ const AdminValidations = () => {
|
|||||||
>
|
>
|
||||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('users.approve') && (
|
||||||
|
<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' ? (
|
||||||
|
<>
|
||||||
|
{hasPermission('subscriptions.activate') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleActivatePayment(user)}
|
onClick={() => handleActivatePayment(user)}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -377,7 +455,23 @@ const AdminValidations = () => {
|
|||||||
<CheckCircle className="h-4 w-4 mr-1" />
|
<CheckCircle className="h-4 w-4 mr-1" />
|
||||||
Activate Payment
|
Activate Payment
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission('users.approve') && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{hasPermission('users.approve') && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleValidateRequest(user)}
|
onClick={() => handleValidateRequest(user)}
|
||||||
disabled={actionLoading === user.id}
|
disabled={actionLoading === user.id}
|
||||||
@@ -387,6 +481,20 @@ const AdminValidations = () => {
|
|||||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{hasPermission('users.approve') && (
|
||||||
|
<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>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -502,6 +610,15 @@ const AdminValidations = () => {
|
|||||||
loading={actionLoading !== null}
|
loading={actionLoading !== null}
|
||||||
{...getActionMessage()}
|
{...getActionMessage()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Rejection Dialog */}
|
||||||
|
<RejectionDialog
|
||||||
|
open={rejectionDialogOpen}
|
||||||
|
onOpenChange={setRejectionDialogOpen}
|
||||||
|
onConfirm={confirmRejection}
|
||||||
|
user={userToReject}
|
||||||
|
loading={actionLoading !== null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
93
src/pages/members/MemberCalendar.css
Normal file
93
src/pages/members/MemberCalendar.css
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/* Member Calendar Custom Styles */
|
||||||
|
|
||||||
|
.member-calendar .rbc-header {
|
||||||
|
padding: 12px 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #422268;
|
||||||
|
background-color: #f9f7fc;
|
||||||
|
border-bottom: 2px solid #ddd8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-today {
|
||||||
|
background-color: #f1eef9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-off-range-bg {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-event {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-event:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button {
|
||||||
|
color: #664fa3;
|
||||||
|
border-color: #ddd8eb;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button:hover {
|
||||||
|
background-color: #f1eef9;
|
||||||
|
border-color: #664fa3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button:active,
|
||||||
|
.member-calendar .rbc-toolbar button.rbc-active {
|
||||||
|
background-color: #664fa3;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-month-view {
|
||||||
|
border: 1px solid #ddd8eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-day-bg {
|
||||||
|
border-color: #ddd8eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-date-cell {
|
||||||
|
padding: 8px;
|
||||||
|
font-family: 'Nunito Sans', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure toolbar buttons are clickable */
|
||||||
|
.member-calendar .rbc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-toolbar button {
|
||||||
|
outline: none;
|
||||||
|
border: 1px solid #ddd8eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-calendar .rbc-btn-group button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
|
|||||||
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||||
|
import './MemberCalendar.css';
|
||||||
import api from '../../utils/api';
|
import api from '../../utils/api';
|
||||||
import Navbar from '../../components/Navbar';
|
import Navbar from '../../components/Navbar';
|
||||||
import MemberFooter from '../../components/MemberFooter';
|
import MemberFooter from '../../components/MemberFooter';
|
||||||
@@ -26,6 +27,8 @@ export default function MemberCalendar() {
|
|||||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [rsvpLoading, setRsvpLoading] = useState(false);
|
const [rsvpLoading, setRsvpLoading] = useState(false);
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [currentView, setCurrentView] = useState('month');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
@@ -58,6 +61,14 @@ export default function MemberCalendar() {
|
|||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (newDate) => {
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewChange = (newView) => {
|
||||||
|
setCurrentView(newView);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRSVP = async (status) => {
|
const handleRSVP = async (status) => {
|
||||||
if (!selectedEvent) return;
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
@@ -171,10 +182,13 @@ export default function MemberCalendar() {
|
|||||||
startAccessor="start"
|
startAccessor="start"
|
||||||
endAccessor="end"
|
endAccessor="end"
|
||||||
style={{ height: 700 }}
|
style={{ height: 700 }}
|
||||||
|
date={currentDate}
|
||||||
|
view={currentView}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onView={handleViewChange}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
eventPropGetter={eventStyleGetter}
|
eventPropGetter={eventStyleGetter}
|
||||||
views={['month', 'week', 'day', 'agenda']}
|
views={['month', 'week', 'day', 'agenda']}
|
||||||
defaultView="month"
|
|
||||||
popup
|
popup
|
||||||
className="member-calendar"
|
className="member-calendar"
|
||||||
/>
|
/>
|
||||||
@@ -320,64 +334,6 @@ export default function MemberCalendar() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style jsx global>{`
|
|
||||||
.member-calendar .rbc-header {
|
|
||||||
padding: 12px 6px;
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #422268;
|
|
||||||
background-color: #f9f7fc;
|
|
||||||
border-bottom: 2px solid #ddd8eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-today {
|
|
||||||
background-color: #f1eef9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-off-range-bg {
|
|
||||||
background-color: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-event {
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 2px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-event:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button {
|
|
||||||
color: #664fa3;
|
|
||||||
border-color: #ddd8eb;
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button:hover {
|
|
||||||
background-color: #f1eef9;
|
|
||||||
border-color: #664fa3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-toolbar button.rbc-active {
|
|
||||||
background-color: #664fa3;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-month-view {
|
|
||||||
border: 1px solid #ddd8eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-day-bg {
|
|
||||||
border-color: #ddd8eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-calendar .rbc-date-cell {
|
|
||||||
padding: 8px;
|
|
||||||
font-family: 'Nunito Sans', sans-serif;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ const MembersDirectory = () => {
|
|||||||
const [selectedMember, setSelectedMember] = useState(null);
|
const [selectedMember, setSelectedMember] = useState(null);
|
||||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const pageSize = 12;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMembers();
|
fetchMembers();
|
||||||
@@ -33,6 +35,10 @@ const MembersDirectory = () => {
|
|||||||
filterMembers();
|
filterMembers();
|
||||||
}, [searchQuery, members]);
|
}, [searchQuery, members]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [searchQuery, members]);
|
||||||
|
|
||||||
const fetchMembers = async () => {
|
const fetchMembers = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/members/directory');
|
const response = await api.get('/members/directory');
|
||||||
@@ -66,6 +72,14 @@ const MembersDirectory = () => {
|
|||||||
setFilteredMembers(filtered);
|
setFilteredMembers(filtered);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
|
||||||
|
|
||||||
|
const pageStart = (currentPage - 1) * pageSize;
|
||||||
|
|
||||||
|
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
|
||||||
|
|
||||||
|
const totalMembers = members.length;
|
||||||
|
|
||||||
const getInitials = (firstName, lastName) => {
|
const getInitials = (firstName, lastName) => {
|
||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||||
};
|
};
|
||||||
@@ -97,9 +111,15 @@ const MembersDirectory = () => {
|
|||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
||||||
};
|
};
|
||||||
|
const Border = ({ yaxis = false }) => {
|
||||||
|
return (
|
||||||
|
yaxis ?
|
||||||
|
<div className=' border-2 w-full border-[#664FA3] my-24' />
|
||||||
|
: <div className=' border-2 w-full border-[#664FA3] mb-24' />
|
||||||
|
)
|
||||||
|
}
|
||||||
const MemberCard = ({ member }) => (
|
const MemberCard = ({ member }) => (
|
||||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
|
<Card className="p-6 bg-white rounded-3xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
|
||||||
{/* Profile Photo */}
|
{/* Profile Photo */}
|
||||||
<div className="flex justify-center mb-4">
|
<div className="flex justify-center mb-4">
|
||||||
{member.profile_photo_url ? (
|
{member.profile_photo_url ? (
|
||||||
@@ -140,15 +160,17 @@ const MembersDirectory = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Member Since */}
|
{/* Member Since */}
|
||||||
|
{member.created_at && (
|
||||||
<div className="flex items-center justify-center gap-2 mb-4">
|
<div className="flex items-center justify-center gap-2 mb-4">
|
||||||
<Calendar className="h-4 w-4 text-[#664fa3]" />
|
<Calendar className="h-4 w-4 text-[#664fa3]" />
|
||||||
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
Member since {new Date(member.member_since || member.created_at).toLocaleDateString('en-US', {
|
Member since {new Date(member.created_at).toLocaleDateString('en-US', {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric'
|
year: 'numeric'
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Contact Information */}
|
{/* Contact Information */}
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
@@ -257,30 +279,34 @@ const MembersDirectory = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white">
|
<div className="min-h-screen bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB]">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
<div className="max-w-7xl mx-auto py-12">
|
||||||
|
|
||||||
|
{/* Header and Search bar */}
|
||||||
|
<div className='px-9'>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
|
||||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
<h1 className="text-4xl md:text-5xl font-bold text-[#422268] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||||
Members Directory
|
LOAF Members
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
Connect with fellow LOAF members in our community.
|
<span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-[#664fa3] font-medium'>{totalMembers}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="mb-8">
|
<div className="mb-24 mx-10">
|
||||||
<div className="relative max-w-xl">
|
<div className="relative w-full ">
|
||||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by name or bio..."
|
placeholder="Search by name or bio..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-12 pr-4 py-6 text-lg border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,6 +317,11 @@ const MembersDirectory = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{/* Border Decoration */}
|
||||||
|
|
||||||
|
<Border />
|
||||||
|
|
||||||
{/* Members Grid */}
|
{/* Members Grid */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-20">
|
<div className="text-center py-20">
|
||||||
@@ -298,7 +329,7 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : filteredMembers.length > 0 ? (
|
) : filteredMembers.length > 0 ? (
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{filteredMembers.map((member) => (
|
{paginatedMembers.map((member) => (
|
||||||
<MemberCard key={member.id} member={member} />
|
<MemberCard key={member.id} member={member} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +347,11 @@ const MembersDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Border Decoration */}
|
||||||
|
<Border yaxis="true" />
|
||||||
|
|
||||||
{/* Info Card */}
|
{/* Info Card */}
|
||||||
{!loading && members.length > 0 && (
|
{!loading && members.length > 0 && (
|
||||||
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
|
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
|
||||||
@@ -524,6 +560,66 @@ const MembersDirectory = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!loading && filteredMembers.length > 0 && (
|
||||||
|
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
|
||||||
|
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||||
|
Showing {pageStart + 1}–{Math.min(pageStart + pageSize, filteredMembers.length)} of {filteredMembers.length}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
|
||||||
|
>
|
||||||
|
First Page
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, index) => {
|
||||||
|
const pageNumber = index + 1;
|
||||||
|
const isActive = pageNumber === currentPage;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNumber}
|
||||||
|
onClick={() => setCurrentPage(pageNumber)}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
|
||||||
|
: "bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] hover:text-white rounded-full"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{pageNumber}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
|
||||||
|
>
|
||||||
|
Last Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<MemberFooter />
|
<MemberFooter />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,14 +4,60 @@ const API_URL = process.env.REACT_APP_BACKEND_URL;
|
|||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: `${API_URL}/api`,
|
baseURL: `${API_URL}/api`,
|
||||||
|
timeout: 30000, // 30 second timeout for all requests
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use((config) => {
|
// Request interceptor - add auth token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('[API] Request error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle errors and retries
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config;
|
||||||
|
|
||||||
|
// Don't retry if we've already retried or if it's a client error (4xx)
|
||||||
|
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
|
||||||
|
console.error('[API] Request failed:', {
|
||||||
|
url: config?.url,
|
||||||
|
method: config?.method,
|
||||||
|
status: error.response?.status,
|
||||||
|
message: error.message,
|
||||||
|
data: error.response?.data
|
||||||
|
});
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as retry to prevent infinite loops
|
||||||
|
config.__isRetry = true;
|
||||||
|
|
||||||
|
// Retry after 1 second for server errors or network issues
|
||||||
|
console.warn('[API] Retrying request after 1s:', {
|
||||||
|
url: config.url,
|
||||||
|
method: config.method,
|
||||||
|
error: error.message
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(api.request(config));
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default api;
|
export default api;
|
||||||
|
|||||||
Reference in New Issue
Block a user